--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 7eea421940d2baf28e6476c2736f889f5f3657a4
Parents : d3dfa64
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-06-05T18:30:56-05:00
feat(android): refactor RNode interface handling and add support for Android storage management
Changes
35 files changed, 2472 insertions(+), 1161 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index b4834f13..e5cb2321 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -783,44 +783,14 @@ class ReticulumMeshChat:
@staticmethod
def _disable_rnode_interfaces_on_android(config_path: str) -> bool:
- """If running on Android, disable RNode* interfaces in Reticulum config.
-
- Returns True if any interfaces were disabled.
- """
+ """Disable enabled RNode* interfaces in Reticulum config (Android recovery helper)."""
if not _is_chaquopy_android():
return False
- if not os.path.isfile(config_path):
- return False
- try:
- from RNS.vendor.configobj import ConfigObj
-
- cfg = ConfigObj(config_path)
- except Exception:
- return False
+ from meshchatx.src.backend.rnode_support import (
+ disable_rnode_interfaces_in_config,
+ )
- modified = False
- interfaces = cfg.get("interfaces")
- if not isinstance(interfaces, dict):
- return False
- for _iface_name, iface in interfaces.items():
- if not isinstance(iface, dict):
- continue
- iface_type = iface.get("type", "")
- if isinstance(iface_type, str) and iface_type.startswith("RNode"):
- if str(iface.get("interface_enabled", "")).lower() in (
- "true",
- "yes",
- "1",
- "on",
- ):
- iface["interface_enabled"] = "false"
- modified = True
- if modified:
- try:
- cfg.write()
- except Exception:
- pass
- return modified
+ return disable_rnode_interfaces_in_config(config_path)
def _ensure_reticulum_config(self, materialize: bool = True):
"""Normalize ``reticulum_config_dir`` and optionally ensure a ``config`` file exists.
@@ -863,13 +833,11 @@ class ReticulumMeshChat:
cfg.write()
except Exception:
pass
- # Android: RNodeInterface crashes because serial port access isn't available
- if _is_chaquopy_android():
- disabled = self._disable_rnode_interfaces_on_android(config_path)
- if disabled:
- logging.getLogger(__name__).warning(
- "RNodeInterface is not supported on Android; disabled in config.",
- )
+ from meshchatx.src.backend.rnode_support import (
+ guard_rnode_interfaces_on_android,
+ )
+
+ guard_rnode_interfaces_on_android(config_path)
def setup_identity(self, identity: RNS.Identity):
identity_hash = identity.hash.hex()
@@ -2655,7 +2623,45 @@ class ReticulumMeshChat:
ctx = context or self.current_context
if not ctx or not ctx.message_router:
return
- ctx.message_router.cancel_propagation_node_requests()
+ router = ctx.message_router
+ with contextlib.suppress(Exception):
+ router.cancel_propagation_node_requests()
+ # cancel_propagation_node_requests resets via acknowledge_sync_completion,
+ # but a blocked RNS.Identity.recall can leave the router in an active state.
+ with contextlib.suppress(Exception):
+ active_states = {
+ router.PR_PATH_REQUESTED,
+ router.PR_LINK_ESTABLISHING,
+ router.PR_LINK_ESTABLISHED,
+ router.PR_REQUEST_SENT,
+ router.PR_RECEIVING,
+ router.PR_RESPONSE_RECEIVED,
+ }
+ if router.propagation_transfer_state in active_states:
+ router.propagation_transfer_state = router.PR_IDLE
+ router.propagation_transfer_progress = 0.0
+
+ async def _request_propagation_node_messages(self, context=None):
+ ctx = context or self.current_context
+ if not ctx or not ctx.message_router:
+ return
+
+ router = ctx.message_router
+
+ def _request():
+ try:
+ router.request_messages_from_propagation_node(ctx.identity)
+ except (EOFError, BrokenPipeError, ConnectionResetError, OSError):
+ with contextlib.suppress(Exception):
+ router.propagation_transfer_state = router.PR_IDLE
+ router.propagation_transfer_progress = 0.0
+ except Exception:
+ logging.getLogger("meshchatx").exception(
+ "Propagation node message request failed",
+ )
+
+ await asyncio.to_thread(_request)
+ await self.send_config_to_websocket_clients(context=ctx)
def _get_propagation_sync_metrics(self, context=None):
ctx = context or self.current_context
@@ -4848,6 +4854,24 @@ class ReticulumMeshChat:
# update interface details
interface_details["type"] = interface_type
+ if interface_type in (
+ "RNodeInterface",
+ "RNodeIPInterface",
+ "RNodeMultiInterface",
+ ):
+ from meshchatx.src.backend.rnode_support import rnode_serial_supported
+
+ if not rnode_serial_supported():
+ return web.json_response(
+ {
+ "message": (
+ "RNode serial and Bluetooth are not available on this device. "
+ "On Android, the app must include usbserial4a (see MeshChatX issue #6)."
+ ),
+ },
+ status=422,
+ )
+
# if interface doesn't have enabled or interface_enabled setting already, enable it by default
if (
"enabled" not in interface_details
@@ -14180,8 +14204,10 @@ class ReticulumMeshChat:
await self.send_config_to_websocket_clients(context=ctx)
return
- # request messages from propagation node
- router.request_messages_from_propagation_node(ctx.identity)
+ # Kick off the LXMF request on a worker thread. Identity.recall and link
+ # setup can block on multiprocessing pipes; running inline would stall the
+ # HTTP handler and race with cancel_propagation_node_requests (EOFError).
+ asyncio.create_task(self._request_propagation_node_messages(context=ctx))
# send config to websocket clients (used to tell ui last synced at)
await self.send_config_to_websocket_clients(context=ctx)
@@ -18018,8 +18044,6 @@ class ReticulumMeshChat:
should_update_message = True
while should_update_message:
- await asyncio.sleep(1)
-
progress_pct = round(lxmf_message.progress * 100, 2)
ctx.database.messages.update_lxmf_message_state(
message_hash=lxmf_message.hash.hex(),
@@ -18069,6 +18093,8 @@ class ReticulumMeshChat:
# check if we should stop updating
if has_delivered or has_propagated or has_failed or is_cancelled:
should_update_message = False
+ else:
+ await asyncio.sleep(1)
def on_telephone_announce_received(
self,
diff --git a/meshchatx/src/backend/rnode_support.py b/meshchatx/src/backend/rnode_support.py
new file mode 100644
index 00000000..dfffb8fa
--- /dev/null
+++ b/meshchatx/src/backend/rnode_support.py
@@ -0,0 +1,100 @@
+# SPDX-License-Identifier: 0BSD
+
+"""RNode USB serial / BLE UART support checks for desktop and Android."""
+
+from __future__ import annotations
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def _is_chaquopy_android() -> bool:
+ try:
+ from meshchatx.android_push_bridge import _is_chaquopy_android as _check
+
+ return _check()
+ except ImportError:
+ return False
+
+
+def android_usbserial4a_available() -> bool:
+ """True when Chaquopy can import usbserial4a (RNS RNode on Android)."""
+ if not _is_chaquopy_android():
+ return False
+ try:
+ import usbserial4a # noqa: F401
+ except ImportError:
+ return False
+ return True
+
+
+def desktop_serial_stack_available() -> bool:
+ try:
+ from serial.tools import list_ports # noqa: F401
+ except ImportError:
+ return False
+ return True
+
+
+def rnode_serial_supported() -> bool:
+ """Whether RNode serial and ble:// UART ports can be opened on this platform."""
+ if _is_chaquopy_android():
+ return android_usbserial4a_available()
+ return desktop_serial_stack_available()
+
+
+def disable_rnode_interfaces_in_config(config_path: str) -> bool:
+ """Disable enabled RNode* interfaces in a Reticulum config file.
+
+ Returns True if any interfaces were disabled.
+ """
+ import os
+
+ if not os.path.isfile(config_path):
+ return False
+ try:
+ from RNS.vendor.configobj import ConfigObj
+
+ cfg = ConfigObj(config_path)
+ except Exception:
+ return False
+
+ modified = False
+ interfaces = cfg.get("interfaces")
+ if not isinstance(interfaces, dict):
+ return False
+ for _iface_name, iface in interfaces.items():
+ if not isinstance(iface, dict):
+ continue
+ iface_type = iface.get("type", "")
+ if isinstance(iface_type, str) and iface_type.startswith("RNode"):
+ if str(iface.get("interface_enabled", "")).lower() in (
+ "true",
+ "yes",
+ "1",
+ "on",
+ ):
+ iface["interface_enabled"] = "false"
+ modified = True
+ if modified:
+ try:
+ cfg.write()
+ except Exception:
+ pass
+ return modified
+
+
+def guard_rnode_interfaces_on_android(config_path: str) -> bool:
+ """On Android without usbserial4a, disable RNode interfaces to avoid startup crashes."""
+ if not _is_chaquopy_android():
+ return False
+ if rnode_serial_supported():
+ return False
+ disabled = disable_rnode_interfaces_in_config(config_path)
+ if disabled:
+ logger.warning(
+ "RNode interfaces were disabled because usbserial4a is not installed. "
+ "Rebuild the Android app with usbserial4a or remove RNode entries from config.",
+ )
+ return disabled
diff --git a/meshchatx/src/backend/rrc/server.py b/meshchatx/src/backend/rrc/server.py
index d21e4a05..c41db8e2 100644
--- a/meshchatx/src/backend/rrc/server.py
+++ b/meshchatx/src/backend/rrc/server.py
@@ -108,6 +108,7 @@ class RRCHubServer:
self.destination = None
self.running = False
+ self._started_at = None
self._lock = threading.RLock()
self._sessions = {}
@@ -145,6 +146,7 @@ class RRCHubServer:
)
self.destination.set_link_established_callback(self._on_link)
self.running = True
+ self._started_at = time.time()
if self.announce:
self.announce_now()
self._log("hub started at " + self.dest_hash.hex())
@@ -165,6 +167,7 @@ class RRCHubServer:
dest = self.destination
self.destination = None
self.running = False
+ self._started_at = None
for link in links:
with contextlib.suppress(Exception):
link.teardown()
@@ -905,12 +908,16 @@ class RRCHubServer:
cfg["members"] = len(self._room_members.get(name, set()))
rooms.append(cfg)
policy = self.policy.to_dict()
+ uptime_seconds = 0
+ if self.running and self._started_at is not None:
+ uptime_seconds = max(0, int(time.time() - self._started_at))
return {
"id": self.hub_id,
"name": self.name,
"dest_hash": self.dest_hash.hex() if self.dest_hash else None,
"enabled": self.enabled,
"running": self.running,
+ "uptime_seconds": uptime_seconds,
"announce": self.announce,
"greeting": self.greeting,
"clients": sum(1 for s in self._sessions.values() if s.welcomed),
diff --git a/meshchatx/src/frontend/components/AndroidStorageChoicePrompt.vue b/meshchatx/src/frontend/components/AndroidStorageChoicePrompt.vue
new file mode 100644
index 00000000..b2b152dd
--- /dev/null
+++ b/meshchatx/src/frontend/components/AndroidStorageChoicePrompt.vue
@@ -0,0 +1,199 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <AppUpdatePrompt
+ :model-value="visible"
+ :title="promptTitle"
+ :description="promptDescription"
+ :primary-label="primaryLabel"
+ :secondary-label="secondaryLabel"
+ :busy="busy"
+ :busy-text="$t('android_storage.working')"
+ :primary-disabled="setupMode && !selectedSetupMode"
+ @update:model-value="onVisibleUpdate"
+ @primary="onPrimary"
+ @secondary="onSecondary"
+ >
+ <div v-if="setupMode" class="space-y-2 text-left">
+ <label
+ class="flex items-start gap-3 p-3 rounded-xl border cursor-pointer transition-colors"
+ :class="
+ selectedSetupMode === 'external'
+ ? 'border-blue-500 bg-blue-50/80 dark:bg-blue-950/30'
+ : 'border-gray-200 dark:border-zinc-800'
+ "
+ >
+ <input v-model="selectedSetupMode" type="radio" class="mt-1" value="external" />
+ <span>
+ <span class="font-medium text-gray-900 dark:text-zinc-100 block">
+ {{ $t("android_storage.setup_external_title") }}
+ </span>
+ <span class="text-xs text-gray-600 dark:text-zinc-400">
+ {{ $t("android_storage.setup_external_desc") }}
+ </span>
+ </span>
+ </label>
+ <label
+ class="flex items-start gap-3 p-3 rounded-xl border cursor-pointer transition-colors"
+ :class="
+ selectedSetupMode === 'internal'
+ ? 'border-blue-500 bg-blue-50/80 dark:bg-blue-950/30'
+ : 'border-gray-200 dark:border-zinc-800'
+ "
+ >
+ <input v-model="selectedSetupMode" type="radio" class="mt-1" value="internal" />
+ <span>
+ <span class="font-medium text-gray-900 dark:text-zinc-100 block">
+ {{ $t("android_storage.setup_internal_title") }}
+ </span>
+ <span class="text-xs text-gray-600 dark:text-zinc-400">
+ {{ $t("android_storage.setup_internal_desc") }}
+ </span>
+ </span>
+ </label>
+ </div>
+ <p v-if="status?.active_path" class="text-[10px] font-mono text-gray-500 dark:text-zinc-500 break-all">
+ {{ status.active_path }}
+ </p>
+ </AppUpdatePrompt>
+</template>
+
+<script>
+import AppUpdatePrompt from "./AppUpdatePrompt.vue";
+import AndroidStorageBridge from "../js/AndroidStorageBridge.js";
+import ToastUtils from "../js/ToastUtils.js";
+
+export default {
+ name: "AndroidStorageChoicePrompt",
+ components: { AppUpdatePrompt },
+ props: {
+ variant: {
+ type: String,
+ default: "upgrade",
+ validator: (v) => v === "setup" || v === "upgrade",
+ },
+ },
+ emits: ["completed", "dismissed"],
+ data() {
+ return {
+ visible: false,
+ busy: false,
+ status: null,
+ selectedSetupMode: "external",
+ storageBridge: null,
+ };
+ },
+ computed: {
+ setupMode() {
+ return this.variant === "setup";
+ },
+ promptTitle() {
+ return this.setupMode ? this.$t("android_storage.setup_title") : this.$t("android_storage.upgrade_title");
+ },
+ promptDescription() {
+ return this.setupMode ? this.$t("android_storage.setup_desc") : this.$t("android_storage.upgrade_desc");
+ },
+ primaryLabel() {
+ return this.setupMode ? this.$t("android_storage.setup_continue") : this.$t("android_storage.upgrade_copy");
+ },
+ secondaryLabel() {
+ return this.setupMode ? "" : this.$t("android_storage.upgrade_stay_internal");
+ },
+ },
+ created() {
+ this.storageBridge = new AndroidStorageBridge();
+ },
+ methods: {
+ ensureStorageBridge() {
+ if (!this.storageBridge) {
+ this.storageBridge = new AndroidStorageBridge();
+ }
+ return this.storageBridge;
+ },
+ refreshStatus() {
+ this.status = this.ensureStorageBridge().getStatus();
+ return this.status;
+ },
+ shouldShowSetup() {
+ const s = this.refreshStatus();
+ return Boolean(s?.needs_setup_choice);
+ },
+ shouldShowUpgrade() {
+ const s = this.refreshStatus();
+ return Boolean(s?.needs_upgrade_prompt);
+ },
+ showSetup() {
+ if (!this.shouldShowSetup()) {
+ return false;
+ }
+ this.selectedSetupMode = "external";
+ this.visible = true;
+ return true;
+ },
+ showUpgrade() {
+ if (!this.shouldShowUpgrade()) {
+ return false;
+ }
+ this.visible = true;
+ return true;
+ },
+ hide() {
+ this.visible = false;
+ },
+ onVisibleUpdate(val) {
+ this.visible = val;
+ if (!val) {
+ this.$emit("dismissed");
+ }
+ },
+ async onPrimary() {
+ if (this.busy) {
+ return;
+ }
+ this.busy = true;
+ try {
+ if (this.setupMode) {
+ const mode = this.selectedSetupMode || "external";
+ const result = this.ensureStorageBridge().applySetupChoice(mode, this.status);
+ this.hide();
+ this.$emit("completed", { action: "setup", mode, restarted: result.restarted });
+ if (result.restarted) {
+ ToastUtils.success(this.$t("android_storage.restart_to_apply"));
+ }
+ return;
+ }
+ if (!this.ensureStorageBridge().scheduleCopyToExternalAndRestart()) {
+ ToastUtils.error(this.$t("android_storage.failed"));
+ return;
+ }
+ ToastUtils.success(this.$t("android_storage.copy_restart_hint"));
+ this.ensureStorageBridge().restartApp();
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(this.$t("android_storage.failed"));
+ } finally {
+ this.busy = false;
+ }
+ },
+ async onSecondary() {
+ if (this.busy || this.setupMode) {
+ return;
+ }
+ this.busy = true;
+ try {
+ if (!this.ensureStorageBridge().keepInternalAndDismiss()) {
+ ToastUtils.error(this.$t("android_storage.failed"));
+ return;
+ }
+ this.hide();
+ this.$emit("completed", { action: "stay_internal" });
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(this.$t("android_storage.failed"));
+ } finally {
+ this.busy = false;
+ }
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index dc6769ff..5103736d 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -643,6 +643,11 @@
<IntegrityWarningModal />
<ChangelogModal ref="changelogModal" :app-version="appInfo?.version" />
<TutorialModal ref="tutorialModal" />
+ <AndroidStorageChoicePrompt
+ ref="androidStorageUpgradePrompt"
+ variant="upgrade"
+ @completed="onAndroidStorageUpgradeCompleted"
+ />
<!-- LXMF QR modal -->
<div
@@ -754,6 +759,7 @@ import CommandPalette from "./CommandPalette.vue";
import IntegrityWarningModal from "./IntegrityWarningModal.vue";
import ChangelogModal from "./ChangelogModal.vue";
import TutorialModal from "./TutorialModal.vue";
+import AndroidStorageChoicePrompt from "./AndroidStorageChoicePrompt.vue";
import AppShellBanners from "./layout/AppShellBanners.vue";
import KeyboardShortcuts from "../js/KeyboardShortcuts";
import ElectronUtils from "../js/ElectronUtils";
@@ -776,6 +782,7 @@ export default {
IntegrityWarningModal,
ChangelogModal,
TutorialModal,
+ AndroidStorageChoicePrompt,
AppShellBanners,
},
setup() {
@@ -1274,6 +1281,16 @@ export default {
onShowTutorialShell() {
this.$refs.tutorialModal?.show();
},
+ maybeShowAndroidStorageUpgrade() {
+ const prompt = this.$refs.androidStorageUpgradePrompt;
+ if (!prompt || typeof prompt.showUpgrade !== "function") {
+ return false;
+ }
+ return prompt.showUpgrade();
+ },
+ onAndroidStorageUpgradeCompleted() {
+ // prompt handles restart when user copies to external storage
+ },
updateUnreadConversationsCount() {
if (this._unreadCountTimeout) {
clearTimeout(this._unreadCountTimeout);
@@ -1574,6 +1591,8 @@ export default {
this.hasCheckedForModals = true;
if (this.appInfo && !this.appInfo.tutorial_seen) {
this.$refs.tutorialModal.show();
+ } else if (this.maybeShowAndroidStorageUpgrade()) {
+ // upgrade prompt for existing internal-storage installs
} else if (
this.appInfo &&
this.appInfo.changelog_seen_version !== "999.999.999" &&
@@ -1749,6 +1768,8 @@ export default {
// Guard to prevent overlapping poll calls
this._isPropagationSyncPolling = false;
+ const pollStartedAt = Date.now();
+ const propagationSyncPollTimeoutMs = 120000;
const poll = async () => {
if (this._isPropagationSyncPolling) return;
@@ -1756,6 +1777,19 @@ export default {
try {
await this.updatePropagationNodeStatus();
if (this.isSyncingPropagationNode) {
+ if (Date.now() - pollStartedAt > propagationSyncPollTimeoutMs) {
+ if (this._propagationSyncPollTimer != null) {
+ clearInterval(this._propagationSyncPollTimer);
+ this._propagationSyncPollTimer = null;
+ }
+ await this.stopSyncingPropagationNode();
+ ToastUtils.error(
+ this.$t("app.sync_error", {
+ status: this.propagationSyncStatusLabel("path_timeout"),
+ })
+ );
+ return;
+ }
ToastUtils.loading(this.propagationSyncLiveToastMessage(), 0, propagationSyncToastKey);
return;
}
diff --git a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
index a7a58423..3ab0a09d 100644
--- a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
@@ -224,9 +224,7 @@
:title="cv.outboundSentStatusTitle(entry.items[0].lxmf_message)"
/>
<svg
- v-else-if="
- cv.showRichOutboundPendingUi(entry.items[0]) && cv.isOutboundPendingForUi(entry.items[0])
- "
+ v-if="cv.showRichOutboundPendingUi(entry.items[0]) && cv.isOutboundPendingForUi(entry.items[0])"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -303,6 +301,24 @@
</button>
</div>
</div>
+ <div
+ v-if="
+ entry.items[0].is_outbound && cv.outboundTransferProgressPercent(entry.items[0].lxmf_message) !== null
+ "
+ class="flex items-center gap-2 justify-end mt-1 w-full min-w-[8rem] max-w-[min(280px,85vw)]"
+ >
+ <div class="flex-1 h-1.5 rounded-full bg-gray-200/90 dark:bg-zinc-700/90 overflow-hidden">
+ <div
+ class="h-full rounded-full bg-blue-500 dark:bg-blue-400 transition-all duration-300"
+ :style="{
+ width: cv.outboundTransferProgressPercent(entry.items[0].lxmf_message) + '%',
+ }"
+ ></div>
+ </div>
+ <span class="text-[11px] font-semibold tabular-nums text-gray-500 dark:text-zinc-400 shrink-0">
+ {{ cv.outboundSendingProgressLabel(entry.items[0].lxmf_message) }}
+ </span>
+ </div>
<div
v-if="cv.expandedMessageInfo === entry.items[0].lxmf_message.hash"
class="mt-2 px-1 text-xs text-gray-500 dark:text-zinc-400 space-y-0.5"
@@ -1081,7 +1097,7 @@
:title="cv.outboundSentStatusTitle(chatItem.lxmf_message)"
/>
<svg
- v-else-if="cv.showRichOutboundPendingUi(chatItem) && cv.isOutboundPendingForUi(chatItem)"
+ v-if="cv.showRichOutboundPendingUi(chatItem) && cv.isOutboundPendingForUi(chatItem)"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -1163,6 +1179,21 @@
</div>
</div>
+ <div
+ v-if="chatItem.is_outbound && cv.outboundTransferProgressPercent(chatItem.lxmf_message) !== null"
+ class="flex items-center gap-2 justify-end mt-1 w-full min-w-[8rem] max-w-[min(280px,85vw)]"
+ >
+ <div class="flex-1 h-1.5 rounded-full bg-gray-200/90 dark:bg-zinc-700/90 overflow-hidden">
+ <div
+ class="h-full rounded-full bg-blue-500 dark:bg-blue-400 transition-all duration-300"
+ :style="{ width: cv.outboundTransferProgressPercent(chatItem.lxmf_message) + '%' }"
+ ></div>
+ </div>
+ <span class="text-[11px] font-semibold tabular-nums text-gray-500 dark:text-zinc-400 shrink-0">
+ {{ cv.outboundSendingProgressLabel(chatItem.lxmf_message) }}
+ </span>
+ </div>
+
<div
v-if="chatItem.lxmf_message.reactions?.length || !chatItem.lxmf_message.is_reaction"
class="flex w-fit max-w-full flex-wrap items-center gap-0.5 px-0.5"
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 88a44b61..c84c8c14 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -3519,12 +3519,16 @@ export default {
return;
}
- const prev = this.chatItems[chatItemIndex].lxmf_message;
+ const chatItem = this.chatItems[chatItemIndex];
+ const prev = chatItem.lxmf_message;
const merged = { ...prev, ...lxmfMessage };
if (!Object.prototype.hasOwnProperty.call(lxmfMessage, "_pendingPathfinding")) {
delete merged._pendingPathfinding;
}
- this.chatItems[chatItemIndex].lxmf_message = merged;
+ this.chatItems[chatItemIndex] = {
+ ...chatItem,
+ lxmf_message: merged,
+ };
},
onLxmfMessageDeleted(hash) {
if (hash) {
@@ -4300,6 +4304,24 @@ export default {
return true;
});
},
+ outboundTransferProgressPercent(lxmfMessage) {
+ if (!lxmfMessage || lxmfMessage._pendingPathfinding) {
+ return null;
+ }
+ const progress = Number(lxmfMessage.progress ?? 0);
+ const state = lxmfMessage.state;
+ if (state === "sending") {
+ return Math.min(100, Math.max(0, Math.round(progress)));
+ }
+ if (progress > 0 && ["outbound", "generating"].includes(state)) {
+ return Math.min(100, Math.max(0, Math.round(progress)));
+ }
+ return null;
+ },
+ outboundSendingProgressLabel(lxmfMessage) {
+ const pct = this.outboundTransferProgressPercent(lxmfMessage);
+ return pct === null ? null : `${pct}%`;
+ },
outboundSendingStatusTooltip(lxmfMessage) {
if (!lxmfMessage) {
return "";
diff --git a/meshchatx/src/frontend/components/relay/RelayChatPage.vue b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
index a5e3cb4b..bded8550 100644
--- a/meshchatx/src/frontend/components/relay/RelayChatPage.vue
+++ b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
@@ -703,146 +703,127 @@
</div>
<!-- host view -->
- <div v-show="view === 'host'" class="flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
- <div class="mx-auto w-full max-w-3xl space-y-4">
- <div class="flex flex-wrap items-start justify-between gap-3">
- <div>
- <h2 class="text-lg font-semibold">{{ $t("relay_chat.host_title") }}</h2>
- <p class="text-sm text-sem-fg-muted">{{ $t("relay_chat.host_subtitle") }}</p>
+ <div v-show="view === 'host'" class="flex min-h-0 flex-1 flex-col overflow-hidden">
+ <RelayHostModerationPage
+ v-if="hostModeration.hub"
+ :hub="hostModeration.hub"
+ :initial-tab="hostModeration.tab"
+ :room-filter="hostModeration.room"
+ @back="closeHostModeration"
+ @refresh="fetchServers"
+ />
+ <div v-else class="flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
+ <div class="mx-auto w-full max-w-3xl space-y-4">
+ <div class="flex flex-wrap items-start justify-between gap-3">
+ <div>
+ <h2 class="text-lg font-semibold">{{ $t("relay_chat.host_title") }}</h2>
+ <p class="text-sm text-sem-fg-muted">{{ $t("relay_chat.host_subtitle") }}</p>
+ </div>
+ <button type="button" :class="btnPrimary" @click="openCreateHub">
+ <MaterialDesignIcon icon-name="plus" class="size-4" />
+ {{ $t("relay_chat.create_hub") }}
+ </button>
</div>
- <button type="button" :class="btnPrimary" @click="openCreateHub">
- <MaterialDesignIcon icon-name="plus" class="size-4" />
- {{ $t("relay_chat.create_hub") }}
- </button>
- </div>
- <div
- v-if="serverHubs.length === 0"
- class="flex flex-col items-center gap-2 rounded-xl border border-sem-border bg-sem-canvas p-8 text-center text-sm text-sem-fg-muted"
- >
- <MaterialDesignIcon icon-name="server-network-off" class="size-10 opacity-40" />
- {{ $t("relay_chat.no_hosted_hubs") }}
- </div>
+ <div
+ v-if="serverHubs.length === 0"
+ class="flex flex-col items-center gap-2 rounded-xl border border-sem-border bg-sem-canvas p-8 text-center text-sm text-sem-fg-muted"
+ >
+ <MaterialDesignIcon icon-name="server-network-off" class="size-10 opacity-40" />
+ {{ $t("relay_chat.no_hosted_hubs") }}
+ </div>
- <div
- v-for="hub in serverHubs"
- :key="hub.id"
- class="rounded-xl border border-sem-border bg-sem-canvas p-4 space-y-3"
- >
- <div class="flex flex-wrap items-start justify-between gap-3">
- <div class="min-w-0 flex-1">
- <div class="flex items-center gap-2">
- <span
- class="size-2 shrink-0 rounded-full"
- :class="hub.running ? 'bg-sem-success' : 'bg-sem-fg-muted'"
- ></span>
- <span class="font-semibold truncate">{{ hub.name }}</span>
+ <div
+ v-for="hub in serverHubs"
+ :key="hub.id"
+ class="rounded-xl border border-sem-border bg-sem-canvas p-4 space-y-3"
+ >
+ <div class="flex flex-wrap items-start justify-between gap-3">
+ <div class="min-w-0 flex-1">
+ <div class="flex items-center gap-2">
+ <span
+ class="size-2 shrink-0 rounded-full"
+ :class="hub.running ? 'bg-sem-success' : 'bg-sem-fg-muted'"
+ ></span>
+ <span class="font-semibold truncate">{{ hub.name }}</span>
+ </div>
+ <button
+ type="button"
+ class="mt-1 flex items-center gap-1.5 text-xs font-mono text-sem-fg-muted hover:text-sem-accent"
+ :title="$t('relay_chat.copy_hash')"
+ @click="copyHash(hub.dest_hash)"
+ >
+ <MaterialDesignIcon icon-name="content-copy" class="size-3.5" />
+ <span class="truncate">{{ formatHash(hub.dest_hash) }}</span>
+ </button>
+ </div>
+ <div class="flex shrink-0 items-center gap-1.5">
+ <button
+ v-if="!hub.running"
+ type="button"
+ :class="[btnSecondary, '!px-2.5 !py-1.5 !text-xs']"
+ @click="startServerHub(hub)"
+ >
+ <MaterialDesignIcon icon-name="play" class="size-4" />
+ {{ $t("relay_chat.host_start") }}
+ </button>
+ <button
+ v-else
+ type="button"
+ :class="[btnSecondary, '!px-2.5 !py-1.5 !text-xs']"
+ @click="stopServerHub(hub)"
+ >
+ <MaterialDesignIcon icon-name="stop" class="size-4" />
+ {{ $t("relay_chat.host_stop") }}
+ </button>
+ <button
+ type="button"
+ :class="btnIcon"
+ :title="$t('relay_chat.host_announce')"
+ :disabled="!hub.running"
+ @click="announceServerHub(hub)"
+ >
+ <MaterialDesignIcon icon-name="bullhorn-outline" class="size-4" />
+ </button>
+ <button
+ type="button"
+ :class="btnDanger"
+ :title="$t('relay_chat.host_delete')"
+ @click="deleteServerHub(hub)"
+ >
+ <MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
+ </button>
</div>
- <button
- type="button"
- class="mt-1 flex items-center gap-1.5 text-xs font-mono text-sem-fg-muted hover:text-sem-accent"
- :title="$t('relay_chat.copy_hash')"
- @click="copyHash(hub.dest_hash)"
- >
- <MaterialDesignIcon icon-name="content-copy" class="size-3.5" />
- <span class="truncate">{{ formatHash(hub.dest_hash) }}</span>
- </button>
</div>
- <div class="flex shrink-0 items-center gap-1.5">
- <button
- v-if="!hub.running"
- type="button"
- :class="[btnSecondary, '!px-2.5 !py-1.5 !text-xs']"
- @click="startServerHub(hub)"
- >
- <MaterialDesignIcon icon-name="play" class="size-4" />
- {{ $t("relay_chat.host_start") }}
- </button>
- <button
- v-else
- type="button"
- :class="[btnSecondary, '!px-2.5 !py-1.5 !text-xs']"
- @click="stopServerHub(hub)"
- >
- <MaterialDesignIcon icon-name="stop" class="size-4" />
- {{ $t("relay_chat.host_stop") }}
- </button>
- <button
- type="button"
- :class="btnIcon"
- :title="$t('relay_chat.host_announce')"
- :disabled="!hub.running"
- @click="announceServerHub(hub)"
- >
- <MaterialDesignIcon icon-name="bullhorn-outline" class="size-4" />
- </button>
- <button
- type="button"
- :class="btnDanger"
- :title="$t('relay_chat.host_delete')"
- @click="deleteServerHub(hub)"
- >
- <MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
- </button>
+
+ <div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-sem-fg-muted">
+ <span class="inline-flex items-center gap-1">
+ <MaterialDesignIcon icon-name="account-group" class="size-3.5" />
+ {{ hub.clients }} {{ $t("relay_chat.host_clients") }}
+ </span>
+ <span class="inline-flex items-center gap-1">
+ <MaterialDesignIcon icon-name="pound" class="size-3.5" />
+ {{ hub.rooms.length }} {{ $t("relay_chat.host_rooms") }}
+ </span>
</div>
- </div>
- <div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-sem-fg-muted">
- <button
- type="button"
- class="inline-flex items-center gap-1 rounded-md px-1 py-0.5 transition-colors hover:bg-sem-surface/60 hover:text-sem-fg"
- :title="$t('relay_chat.show_members')"
- :disabled="!hub.running || hub.clients === 0"
- @click="openHostMembers(hub)"
- >
- <MaterialDesignIcon icon-name="account-group" class="size-3.5" />
- {{ hub.clients }} {{ $t("relay_chat.host_clients") }}
- </button>
<button
type="button"
- class="inline-flex items-center gap-1 rounded-md px-1 py-0.5 transition-colors hover:bg-sem-surface/60 hover:text-sem-fg"
- :title="$t('relay_chat.host_manage_rooms')"
- @click="openHostRooms(hub)"
+ :class="[btnSecondary, 'w-full !py-2 !text-xs']"
+ @click="openHostModeration(hub)"
>
- <MaterialDesignIcon icon-name="pound" class="size-3.5" />
- {{ hub.rooms.length }} {{ $t("relay_chat.host_rooms") }}
+ <MaterialDesignIcon icon-name="shield-account" class="size-4" />
+ {{ $t("relay_chat.host_moderate") }}
</button>
</div>
-
- <button
- type="button"
- :class="[btnSecondary, 'w-full !py-2 !text-xs']"
- @click="openHostRooms(hub)"
- >
- <MaterialDesignIcon icon-name="door-open" class="size-4" />
- {{ $t("relay_chat.host_manage_rooms") }}
- </button>
</div>
</div>
</div>
- <RelayHostMembersModal
- :open="hostMembersModal.open"
- :hub="hostMembersModal.hub"
- :room="hostMembersModal.room"
- @close="closeHostMembers"
- @refresh="fetchServers"
- />
- <RelayHostRoomsModal
- :open="hostRoomsModal.open"
- :hub="hostRoomsModal.hub"
- @close="closeHostRooms"
- @refresh="fetchServers"
- />
-
<!-- create hub dialog -->
- <div
- v-if="showCreateHub"
- class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
- @click.self="showCreateHub = false"
- >
- <div class="w-full max-w-md rounded-2xl border border-sem-border-card bg-sem-surface p-5 shadow-xl">
- <h2 class="mb-4 text-lg font-semibold">{{ $t("relay_chat.create_hub_title") }}</h2>
+ <div v-if="showCreateHub" :class="RELAY_HOST_MODAL_OVERLAY" @click.self="showCreateHub = false">
+ <div :class="RELAY_HOST_MODAL_PANEL_COMPACT" @click.stop>
+ <h2 class="mb-4 text-lg font-semibold text-sem-fg">{{ $t("relay_chat.create_hub_title") }}</h2>
<form class="space-y-4" @submit.prevent="createServerHub">
<div class="space-y-1.5">
<label class="block text-sm font-semibold text-sem-fg-secondary">{{
@@ -1113,10 +1094,10 @@ import { countRelayMentions } from "../../js/relayMentionCount.js";
import { filterRelayMembers, filterRelayMessages } from "../../js/relayMessageSearch.js";
import { buildRelayMessageTimeline, relayMessageKey } from "../../js/relayMessageTimeline.js";
import { loadRelayLayout, saveRelayLayout } from "../../js/relayLayoutStore.js";
+import { RELAY_HOST_MODAL_OVERLAY, RELAY_HOST_MODAL_PANEL_COMPACT } from "../../js/relayHostModalClasses.js";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import MdiIconPickerModal from "../MdiIconPickerModal.vue";
-import RelayHostMembersModal from "./RelayHostMembersModal.vue";
-import RelayHostRoomsModal from "./RelayHostRoomsModal.vue";
+import RelayHostModerationPage from "./RelayHostModerationPage.vue";
import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
import ContextMenuDivider from "../contextmenu/ContextMenuDivider.vue";
@@ -1142,8 +1123,7 @@ export default {
components: {
MaterialDesignIcon,
MdiIconPickerModal,
- RelayHostMembersModal,
- RelayHostRoomsModal,
+ RelayHostModerationPage,
ContextMenuPanel,
ContextMenuItem,
ContextMenuDivider,
@@ -1159,6 +1139,8 @@ export default {
},
data() {
return {
+ RELAY_HOST_MODAL_OVERLAY,
+ RELAY_HOST_MODAL_PANEL_COMPACT,
btnPrimary: BTN_PRIMARY,
btnSecondary: BTN_SECONDARY,
btnIcon: BTN_ICON,
@@ -1234,15 +1216,11 @@ export default {
has_custom_name: false,
hub_icon: null,
},
- hostMembersModal: {
- open: false,
+ hostModeration: {
hub: null,
+ tab: "rooms",
room: null,
},
- hostRoomsModal: {
- open: false,
- hub: null,
- },
};
},
computed: {
@@ -1349,6 +1327,9 @@ export default {
},
methods: {
selectView(view) {
+ if (view !== "host") {
+ this.closeHostModeration();
+ }
this.view = view;
this.persistRelayLayout();
if (view === "discovery") {
@@ -2316,25 +2297,27 @@ export default {
ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
}
},
- openHostMembers(hub, room = null) {
- this.hostMembersModal = {
- open: true,
+ openHostModeration(hub, { tab = "rooms", room = null } = {}) {
+ if (!hub) {
+ return;
+ }
+ if (tab === "members" && !hub.running) {
+ ToastUtils.warning(this.$t("relay_chat.host_hub_not_running"));
+ return;
+ }
+ this.hostModeration = {
hub,
+ tab: tab === "members" ? "members" : "rooms",
room: room || null,
};
},
- closeHostMembers() {
- this.hostMembersModal.open = false;
- },
- openHostRooms(hub) {
- this.hostRoomsModal = {
- open: true,
- hub,
+ closeHostModeration() {
+ this.hostModeration = {
+ hub: null,
+ tab: "rooms",
+ room: null,
};
},
- closeHostRooms() {
- this.hostRoomsModal.open = false;
- },
copyHash(hash) {
if (!hash) {
return;
diff --git a/meshchatx/src/frontend/components/relay/RelayHostMembersModal.vue b/meshchatx/src/frontend/components/relay/RelayHostMembersModal.vue
deleted file mode 100644
index 2c55c3f9..00000000
--- a/meshchatx/src/frontend/components/relay/RelayHostMembersModal.vue
+++ /dev/null
@@ -1,410 +0,0 @@
-<!-- SPDX-License-Identifier: 0BSD -->
-
-<template>
- <div
- v-if="open"
- class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
- @click.self="$emit('close')"
- >
- <div
- class="flex h-[min(100dvh-2rem,900px)] w-full max-w-6xl flex-col rounded-2xl border border-sem-border-card bg-sem-surface shadow-xl"
- role="dialog"
- :aria-label="title"
- >
- <div class="flex shrink-0 items-center gap-2 border-b border-sem-border px-4 py-3 sm:px-5 sm:py-4">
- <button
- v-if="selectedMember && isNarrow"
- type="button"
- class="rounded-lg p-1.5 text-sem-fg-muted hover:bg-sem-surface/60"
- @click="selectedMember = null"
- >
- <MaterialDesignIcon icon-name="arrow-left" class="size-5" />
- </button>
- <h2 class="min-w-0 flex-1 text-lg font-semibold truncate">{{ title }}</h2>
- <button
- type="button"
- class="rounded-lg p-1.5 text-sem-fg-muted hover:bg-sem-surface/60"
- :title="$t('common.close')"
- @click="$emit('close')"
- >
- <MaterialDesignIcon icon-name="close" class="size-5" />
- </button>
- </div>
-
- <div class="shrink-0 border-b border-sem-border px-4 py-2.5 sm:px-5">
- <div class="relative">
- <input
- v-model="search"
- type="search"
- :placeholder="$t('relay_chat.host_members_search')"
- class="input-field !py-2.5 pr-10"
- />
- <MaterialDesignIcon
- icon-name="magnify"
- class="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-sem-fg-muted"
- />
- </div>
- </div>
-
- <div class="flex min-h-0 flex-1 flex-col lg:flex-row">
- <div
- class="flex min-h-0 flex-col border-sem-border lg:w-80 lg:shrink-0 lg:border-r"
- :class="isNarrow && selectedMember ? 'hidden' : 'flex-1 lg:flex-none'"
- >
- <div class="flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
- <div v-if="loading" class="py-12 text-center text-sm text-sem-fg-muted">
- {{ $t("common.loading") }}
- </div>
- <div
- v-else-if="filteredMembers.length === 0"
- class="py-12 text-center text-sm text-sem-fg-muted"
- >
- {{ $t("relay_chat.no_members") }}
- </div>
- <ul v-else class="space-y-1.5">
- <li
- v-for="member in filteredMembers"
- :key="member.hash"
- class="rounded-xl border px-3 py-2.5 transition-colors cursor-pointer"
- :class="
- selectedMember?.hash === member.hash
- ? 'border-sem-accent bg-sem-accent/10'
- : 'border-sem-border hover:bg-sem-surface/50'
- "
- @click="selectMember(member)"
- >
- <div class="flex items-start gap-2">
- <span class="mt-1.5 size-2 shrink-0 rounded-full bg-sem-success"></span>
- <div class="min-w-0 flex-1">
- <div class="truncate font-medium" :style="{ color: colorForHash(member.hash) }">
- {{ member.name }}
- </div>
- <div class="truncate font-mono text-xs text-sem-fg-muted">
- {{ formatHash(member.hash) }}
- </div>
- <div
- v-if="!room && member.rooms?.length"
- class="mt-1 text-xs text-sem-fg-muted truncate"
- >
- {{ member.rooms.map((r) => "#" + r).join(", ") }}
- </div>
- </div>
- <div class="flex shrink-0 items-center gap-0.5">
- <button
- type="button"
- class="rounded-lg p-1.5 text-sem-fg-muted hover:bg-sem-warning/15 hover:text-sem-warning"
- :title="$t('relay_chat.ctx_kick_user')"
- @click.stop="moderate(member, 'kick')"
- >
- <MaterialDesignIcon icon-name="account-remove" class="size-4" />
- </button>
- <button
- type="button"
- class="rounded-lg p-1.5 text-sem-fg-muted hover:bg-sem-danger/15 hover:text-sem-danger"
- :title="$t('relay_chat.host_ban_hub')"
- @click.stop="moderate(member, room ? 'room_ban' : 'ban')"
- >
- <MaterialDesignIcon icon-name="block-helper" class="size-4" />
- </button>
- </div>
- </div>
- </li>
- </ul>
- </div>
- </div>
-
- <div
- class="flex min-h-0 min-w-0 flex-1 flex-col"
- :class="isNarrow && !selectedMember ? 'hidden' : 'flex'"
- >
- <div
- v-if="!selectedMember"
- class="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-sem-fg-muted"
- >
- <MaterialDesignIcon icon-name="account-search" class="size-10 opacity-40" />
- {{ $t("relay_chat.host_members_select") }}
- </div>
- <template v-else>
- <div class="shrink-0 border-b border-sem-border px-4 py-3 sm:px-5">
- <div class="font-semibold" :style="{ color: colorForHash(selectedMember.hash) }">
- {{ selectedMember.name }}
- </div>
- <button
- type="button"
- class="mt-0.5 font-mono text-xs text-sem-fg-muted hover:text-sem-accent"
- @click="copyHash(selectedMember.hash)"
- >
- {{ formatHash(selectedMember.hash) }}
- </button>
- </div>
- <div class="flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
- <div v-if="messagesLoading" class="py-8 text-center text-sm text-sem-fg-muted">
- {{ $t("common.loading") }}
- </div>
- <div
- v-else-if="memberMessages.length === 0"
- class="py-8 text-center text-sm text-sem-fg-muted"
- >
- {{ $t("relay_chat.host_no_messages") }}
- </div>
- <ul v-else class="space-y-2">
- <li
- v-for="(msg, idx) in memberMessages"
- :key="idx"
- class="rounded-lg border border-sem-border bg-sem-canvas px-3 py-2 text-sm"
- >
- <div class="flex flex-wrap items-center gap-x-2 text-xs text-sem-fg-muted">
- <span>#{{ msg.room }}</span>
- <span>{{ formatTime(msg.ts) }}</span>
- <span v-if="msg.kind === 'action'" class="italic">action</span>
- </div>
- <div class="mt-1 whitespace-pre-wrap break-words">{{ msg.text }}</div>
- </li>
- </ul>
- </div>
- </template>
- </div>
- </div>
- </div>
- </div>
-</template>
-
-<script>
-import MaterialDesignIcon from "../MaterialDesignIcon.vue";
-import DialogUtils from "../../js/DialogUtils";
-import ToastUtils from "../../js/ToastUtils";
-
-const NAME_COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#8b5cf6", "#ec4899"];
-
-export default {
- name: "RelayHostMembersModal",
- components: { MaterialDesignIcon },
- props: {
- open: { type: Boolean, default: false },
- hub: { type: Object, default: null },
- room: { type: String, default: null },
- },
- emits: ["close", "refresh"],
- data() {
- return {
- loading: false,
- members: [],
- search: "",
- selectedMember: null,
- memberMessages: [],
- messagesLoading: false,
- isNarrow: false,
- mq: null,
- localIdentityHash: "",
- };
- },
- computed: {
- title() {
- const hubName = this.hub?.name || "";
- if (this.room) {
- return this.$t("relay_chat.host_members_room_title", { hub: hubName, room: this.room });
- }
- return this.$t("relay_chat.host_members_all_title", { hub: hubName });
- },
- filteredMembers() {
- const q = this.search.trim().toLowerCase();
- if (!q) {
- return this.members;
- }
- return this.members.filter((m) => {
- const name = (m.name || "").toLowerCase();
- const hash = (m.hash || "").toLowerCase();
- const rooms = (m.rooms || []).join(" ").toLowerCase();
- return name.includes(q) || hash.includes(q) || rooms.includes(q);
- });
- },
- },
- watch: {
- open(val) {
- if (val) {
- this.search = "";
- this.selectedMember = null;
- this.memberMessages = [];
- this.ensureLocalIdentity();
- this.fetchMembers();
- }
- },
- },
- mounted() {
- this.mq = window.matchMedia("(max-width: 1023px)");
- this.isNarrow = this.mq.matches;
- this.mq.addEventListener("change", this.onMq);
- },
- beforeUnmount() {
- if (this.mq) {
- this.mq.removeEventListener("change", this.onMq);
- }
- },
- methods: {
- onMq() {
- this.isNarrow = this.mq.matches;
- },
- async fetchMembers() {
- if (!this.hub?.id) {
- return;
- }
- this.loading = true;
- try {
- const params = this.room ? { params: { room: this.room } } : {};
- const response = await window.api.get(`/api/v1/rrc/servers/${this.hub.id}/members`, params);
- this.members = response.data?.members || [];
- } catch (e) {
- ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
- this.$emit("close");
- } finally {
- this.loading = false;
- }
- },
- selectMember(member) {
- this.selectedMember = member;
- this.loadMemberMessages();
- },
- async loadMemberMessages() {
- if (!this.selectedMember || !this.hub?.id) {
- return;
- }
- this.messagesLoading = true;
- try {
- const params = { peer: this.selectedMember.hash, limit: 200 };
- if (this.room) {
- params.room = this.room;
- }
- const response = await window.api.get(`/api/v1/rrc/servers/${this.hub.id}/messages`, { params });
- this.memberMessages = response.data?.messages || [];
- } catch {
- this.memberMessages = [];
- } finally {
- this.messagesLoading = false;
- }
- },
- async ensureLocalIdentity() {
- if (this.localIdentityHash) {
- return;
- }
- try {
- const response = await window.api.get("/api/v1/config");
- const hash = response.data?.identity_hash;
- if (typeof hash === "string" && hash.trim()) {
- this.localIdentityHash = hash.trim().toLowerCase();
- }
- } catch {
- // config may be unavailable in tests
- }
- },
- async resolveModerationRoom(member, action) {
- if (this.room) {
- return this.room;
- }
- const needsRoom = action === "kick" || action === "room_ban";
- if (!needsRoom) {
- return null;
- }
- const rooms = (member.rooms || []).filter((r) => typeof r === "string" && r.trim());
- if (rooms.length === 0) {
- ToastUtils.warning(this.$t("relay_chat.host_kick_no_room"));
- return null;
- }
- if (rooms.length === 1) {
- return rooms[0];
- }
- const entered = await DialogUtils.prompt(
- this.$t("relay_chat.host_kick_pick_room", {
- name: member.name,
- rooms: rooms.map((r) => "#" + r).join(", "),
- })
- );
- if (!entered) {
- return null;
- }
- const norm = entered.trim().replace(/^#/, "");
- const match = rooms.find((r) => r.toLowerCase() === norm.toLowerCase());
- if (!match) {
- ToastUtils.warning(this.$t("relay_chat.host_kick_room_invalid"));
- return null;
- }
- return match;
- },
- async moderate(member, action) {
- if (!this.hub?.id || !member?.hash) {
- return;
- }
- await this.ensureLocalIdentity();
- if (this.localIdentityHash && member.hash.toLowerCase() === this.localIdentityHash) {
- ToastUtils.warning(this.$t("relay_chat.host_cannot_moderate_self"));
- return;
- }
- const room = await this.resolveModerationRoom(member, action);
- if ((action === "kick" || action === "room_ban") && !room) {
- return;
- }
- const labels = {
- kick: this.$t("relay_chat.host_kick_confirm", { name: member.name, room }),
- ban: this.$t("relay_chat.host_ban_confirm", { name: member.name }),
- room_ban: this.$t("relay_chat.host_room_ban_confirm", {
- name: member.name,
- room,
- }),
- };
- const confirmed = await DialogUtils.confirm(labels[action] || "");
- if (!confirmed) {
- return;
- }
- try {
- await window.api.post(`/api/v1/rrc/servers/${this.hub.id}/moderate`, {
- action,
- peer: member.hash,
- room: room || undefined,
- });
- ToastUtils.success(this.$t("common.success"));
- this.$emit("refresh");
- await this.fetchMembers();
- if (this.selectedMember?.hash === member.hash) {
- this.selectedMember = null;
- this.memberMessages = [];
- }
- } catch (e) {
- ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
- }
- },
- colorForHash(hash) {
- if (!hash) {
- return undefined;
- }
- let n = 0;
- for (let i = 0; i < hash.length; i++) {
- n = (n + hash.charCodeAt(i)) % NAME_COLORS.length;
- }
- return NAME_COLORS[n];
- },
- formatHash(hash) {
- if (!hash || hash.length < 16) {
- return hash || "";
- }
- return hash.slice(0, 8) + "..." + hash.slice(-8);
- },
- formatTime(ts) {
- if (!ts) {
- return "";
- }
- const d = new Date(ts);
- return d.toLocaleString();
- },
- copyHash(hash) {
- if (!hash) {
- return;
- }
- try {
- navigator.clipboard.writeText(hash);
- ToastUtils.success(this.$t("relay_chat.hash_copied"));
- } catch {
- ToastUtils.error(this.$t("common.failed_to_copy"));
- }
- },
- },
-};
-</script>
diff --git a/meshchatx/src/frontend/components/relay/RelayHostModerationPage.vue b/meshchatx/src/frontend/components/relay/RelayHostModerationPage.vue
new file mode 100644
index 00000000..2237ee60
--- /dev/null
+++ b/meshchatx/src/frontend/components/relay/RelayHostModerationPage.vue
@@ -0,0 +1,825 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div class="flex min-h-0 flex-1 flex-col overflow-hidden bg-sem-canvas text-sem-fg">
+ <div :class="RELAY_HOST_PAGE_HEADER">
+ <button type="button" :class="RELAY_HOST_ICON_BTN" :title="$t('relay_chat.back')" @click="$emit('back')">
+ <MaterialDesignIcon icon-name="arrow-left" class="size-5" />
+ </button>
+ <div class="min-w-0 flex-1">
+ <h2 class="truncate text-lg font-semibold text-sem-fg">{{ pageTitle }}</h2>
+ <p v-if="hub" class="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-sem-fg-muted">
+ <span class="inline-flex items-center gap-1.5">
+ <span
+ class="inline-block size-2 shrink-0 rounded-full"
+ :class="hub.running ? 'bg-sem-success' : 'bg-sem-fg-muted'"
+ ></span>
+ {{ hub.running ? $t("relay_chat.host_status_running") : $t("relay_chat.host_status_stopped") }}
+ </span>
+ <template v-if="hub.running && liveUptimeSeconds > 0">
+ <span class="text-sem-border-strong" aria-hidden="true">·</span>
+ <span>{{
+ $t("relay_chat.host_moderation_uptime", { time: formatUptime(liveUptimeSeconds) })
+ }}</span>
+ </template>
+ <template v-if="navbarMemberCount != null">
+ <span class="text-sem-border-strong" aria-hidden="true">·</span>
+ <span>{{ $t("relay_chat.host_moderation_members", { count: navbarMemberCount }) }}</span>
+ </template>
+ </p>
+ </div>
+ </div>
+
+ <div :class="RELAY_HOST_PAGE_TABS" role="tablist">
+ <button
+ type="button"
+ role="tab"
+ :aria-selected="tab === 'rooms'"
+ :class="[RELAY_HOST_PAGE_TAB, tab === 'rooms' ? RELAY_HOST_PAGE_TAB_ACTIVE : RELAY_HOST_PAGE_TAB_IDLE]"
+ @click="setTab('rooms')"
+ >
+ <MaterialDesignIcon icon-name="pound" class="size-4" />
+ {{ $t("relay_chat.host_moderation_tab_rooms") }}
+ </button>
+ <button
+ type="button"
+ role="tab"
+ :aria-selected="tab === 'members'"
+ :class="[
+ RELAY_HOST_PAGE_TAB,
+ tab === 'members' ? RELAY_HOST_PAGE_TAB_ACTIVE : RELAY_HOST_PAGE_TAB_IDLE,
+ ]"
+ @click="setTab('members')"
+ >
+ <MaterialDesignIcon icon-name="account-group" class="size-4" />
+ {{ $t("relay_chat.host_moderation_tab_members") }}
+ </button>
+ </div>
+
+ <div v-if="!hub" class="flex flex-1 items-center justify-center p-6 text-sm text-sem-fg-muted">
+ {{ $t("relay_chat.host_moderation_hub_missing") }}
+ </div>
+
+ <div v-else-if="tab === 'rooms'" :class="RELAY_HOST_PAGE_BODY">
+ <div :class="[RELAY_HOST_PAGE_LIST, isNarrow && selectedRoom ? 'hidden' : 'flex flex-1 lg:flex-none']">
+ <div class="shrink-0 space-y-3 border-b border-sem-border p-3 sm:p-4">
+ <div class="relative">
+ <input
+ v-model="roomsSearch"
+ type="search"
+ :placeholder="$t('relay_chat.host_rooms_search')"
+ class="input-field !py-2.5 pl-10 pr-3"
+ />
+ <MaterialDesignIcon
+ icon-name="magnify"
+ class="pointer-events-none absolute left-3 top-1/2 size-5 -translate-y-1/2 text-sem-fg-muted"
+ />
+ </div>
+ <button
+ v-if="!showAddRoomForm"
+ type="button"
+ class="flex w-full items-center gap-2 rounded-xl border-2 border-dashed border-sem-border px-3 py-3 text-left text-sm text-sem-fg-muted transition-colors hover:border-sem-accent hover:bg-sem-surface/40 hover:text-sem-accent"
+ @click="showAddRoomForm = true"
+ >
+ <MaterialDesignIcon icon-name="plus-circle-outline" class="size-5 shrink-0" />
+ <span class="font-medium">{{ $t("relay_chat.host_add_room") }}</span>
+ </button>
+ <form
+ v-else
+ class="space-y-2.5 rounded-xl border border-sem-border bg-sem-surface-raised/40 p-3"
+ @submit.prevent="createRoom"
+ >
+ <input
+ v-model="newRoom.name"
+ type="text"
+ :placeholder="$t('relay_chat.host_room_name')"
+ class="input-field w-full !py-2.5 !text-sm"
+ autofocus
+ />
+ <input
+ v-model="newRoom.topic"
+ type="text"
+ :placeholder="$t('relay_chat.host_room_topic')"
+ class="input-field w-full !py-2.5 !text-sm"
+ />
+ <div class="flex gap-2 pt-0.5">
+ <button
+ type="submit"
+ :class="[btnPrimary, 'flex-1 !py-2.5 !text-sm']"
+ :disabled="creatingRoom"
+ >
+ <MaterialDesignIcon icon-name="plus" class="size-4" />
+ {{ $t("relay_chat.host_add_room") }}
+ </button>
+ <button
+ type="button"
+ :class="[btnSecondary, '!py-2.5 !text-sm']"
+ :disabled="creatingRoom"
+ @click="cancelAddRoom"
+ >
+ {{ $t("common.cancel") }}
+ </button>
+ </div>
+ </form>
+ </div>
+ <div class="min-h-0 flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
+ <div v-if="roomsLoading" class="py-12 text-center text-sm text-sem-fg-muted">
+ {{ $t("common.loading") }}
+ </div>
+ <div v-else-if="filteredRooms.length === 0" class="py-12 text-center text-sm text-sem-fg-muted">
+ {{
+ roomsSearch.trim()
+ ? $t("relay_chat.host_rooms_search_empty")
+ : $t("relay_chat.host_no_rooms")
+ }}
+ </div>
+ <ul v-else class="space-y-1.5">
+ <li
+ v-for="room in filteredRooms"
+ :key="room.name"
+ class="cursor-pointer px-3 py-2.5"
+ :class="[
+ RELAY_HOST_LIST_ITEM,
+ selectedRoom === room.name ? RELAY_HOST_LIST_ITEM_SELECTED : RELAY_HOST_LIST_ITEM_IDLE,
+ ]"
+ @click="selectedRoom = room.name"
+ >
+ <div class="flex items-start justify-between gap-2">
+ <div class="min-w-0">
+ <div class="font-medium text-sem-fg">#{{ room.name }}</div>
+ <div v-if="room.topic" class="truncate text-xs text-sem-fg-muted">
+ {{ room.topic }}
+ </div>
+ <div class="mt-1 flex flex-wrap gap-x-3 text-xs text-sem-fg-muted">
+ <span>{{ room.members }} {{ $t("relay_chat.host_clients") }}</span>
+ <span>{{ room.message_count || 0 }} msgs</span>
+ </div>
+ </div>
+ <button
+ v-if="room.registered"
+ type="button"
+ class="shrink-0 rounded-lg p-1.5 text-sem-fg-muted hover:text-sem-danger"
+ :title="$t('relay_chat.host_delete_room')"
+ @click.stop="deleteRoom(room.name)"
+ >
+ <MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
+ </button>
+ </div>
+ </li>
+ </ul>
+ </div>
+ </div>
+
+ <div :class="[RELAY_HOST_PAGE_DETAIL, isNarrow && !selectedRoom ? 'hidden' : 'flex']">
+ <div
+ v-if="isNarrow && selectedRoom"
+ class="flex shrink-0 items-center gap-2 border-b border-sem-border px-3 py-2 lg:hidden"
+ >
+ <button type="button" :class="RELAY_HOST_ICON_BTN" @click="selectedRoom = null">
+ <MaterialDesignIcon icon-name="arrow-left" class="size-5" />
+ </button>
+ <span class="font-semibold text-sem-fg">#{{ selectedRoom }}</span>
+ </div>
+ <div
+ v-if="!selectedRoom"
+ class="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-sem-fg-muted"
+ >
+ <MaterialDesignIcon icon-name="pound" class="size-10 opacity-40" />
+ {{ $t("relay_chat.host_rooms_select") }}
+ </div>
+ <template v-else>
+ <div :class="[RELAY_HOST_DETAIL_HEADER, 'hidden lg:block']">
+ <div class="font-semibold text-sem-fg">#{{ selectedRoom }}</div>
+ <div class="text-xs text-sem-fg-muted">{{ $t("relay_chat.host_room_activity") }}</div>
+ </div>
+ <div class="min-h-0 flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
+ <ul v-if="roomMessages.length > 0" class="space-y-2">
+ <li v-for="(msg, idx) in roomMessages" :key="idx" :class="RELAY_HOST_MESSAGE">
+ <div class="flex flex-wrap items-center gap-x-2 text-xs text-sem-fg-muted">
+ <span :style="{ color: colorForHash(msg.peer) }">{{
+ msg.nick || formatHash(msg.peer)
+ }}</span>
+ <span>{{ formatTime(msg.ts) }}</span>
+ </div>
+ <div class="mt-1 whitespace-pre-wrap break-words">{{ msg.text }}</div>
+ </li>
+ </ul>
+ <div v-else class="py-8 text-center text-sm text-sem-fg-muted">
+ {{ $t("relay_chat.host_no_activity") }}
+ </div>
+ </div>
+ </template>
+ </div>
+ </div>
+
+ <div v-else :class="RELAY_HOST_PAGE_BODY">
+ <div :class="[RELAY_HOST_PAGE_LIST, isNarrow && selectedMember ? 'hidden' : 'flex flex-1 lg:flex-none']">
+ <div class="shrink-0 border-b border-sem-border p-3 sm:p-4">
+ <div class="relative">
+ <input
+ v-model="membersSearch"
+ type="search"
+ :placeholder="$t('relay_chat.host_members_search')"
+ class="input-field !py-2.5 pl-10 pr-3"
+ />
+ <MaterialDesignIcon
+ icon-name="magnify"
+ class="pointer-events-none absolute left-3 top-1/2 size-5 -translate-y-1/2 text-sem-fg-muted"
+ />
+ </div>
+ </div>
+ <div class="min-h-0 flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
+ <div v-if="membersLoading" class="py-12 text-center text-sm text-sem-fg-muted">
+ {{ $t("common.loading") }}
+ </div>
+ <div v-else-if="filteredMembers.length === 0" class="py-12 text-center text-sm text-sem-fg-muted">
+ {{ $t("relay_chat.no_members") }}
+ </div>
+ <ul v-else class="space-y-1.5">
+ <li
+ v-for="member in filteredMembers"
+ :key="member.hash"
+ class="cursor-pointer px-3 py-2.5"
+ :class="[
+ RELAY_HOST_LIST_ITEM,
+ selectedMember?.hash === member.hash
+ ? RELAY_HOST_LIST_ITEM_SELECTED
+ : RELAY_HOST_LIST_ITEM_IDLE,
+ ]"
+ @click="selectMember(member)"
+ >
+ <div class="flex items-start gap-2">
+ <span class="mt-1.5 size-2 shrink-0 rounded-full bg-sem-success"></span>
+ <div class="min-w-0 flex-1">
+ <div class="truncate font-medium" :style="{ color: colorForHash(member.hash) }">
+ {{ member.name }}
+ </div>
+ <div class="truncate font-mono text-xs text-sem-fg-muted">
+ {{ formatHash(member.hash) }}
+ </div>
+ <div
+ v-if="!roomFilter && member.rooms?.length"
+ class="mt-1 truncate text-xs text-sem-fg-muted"
+ >
+ {{ member.rooms.map((r) => "#" + r).join(", ") }}
+ </div>
+ </div>
+ <div class="flex shrink-0 items-center gap-0.5">
+ <button
+ type="button"
+ class="rounded-lg p-1.5 text-sem-fg-muted hover:bg-sem-warning/15 hover:text-sem-warning"
+ :title="$t('relay_chat.ctx_kick_user')"
+ @click.stop="moderate(member, 'kick')"
+ >
+ <MaterialDesignIcon icon-name="account-remove" class="size-4" />
+ </button>
+ <button
+ type="button"
+ class="rounded-lg p-1.5 text-sem-fg-muted hover:bg-sem-danger/15 hover:text-sem-danger"
+ :title="$t('relay_chat.host_ban_hub')"
+ @click.stop="moderate(member, roomFilter ? 'room_ban' : 'ban')"
+ >
+ <MaterialDesignIcon icon-name="block-helper" class="size-4" />
+ </button>
+ </div>
+ </div>
+ </li>
+ </ul>
+ </div>
+ </div>
+
+ <div :class="[RELAY_HOST_PAGE_DETAIL, isNarrow && !selectedMember ? 'hidden' : 'flex']">
+ <div
+ v-if="isNarrow && selectedMember"
+ class="flex shrink-0 items-center gap-2 border-b border-sem-border px-3 py-2 lg:hidden"
+ >
+ <button type="button" :class="RELAY_HOST_ICON_BTN" @click="selectedMember = null">
+ <MaterialDesignIcon icon-name="arrow-left" class="size-5" />
+ </button>
+ <span class="truncate font-semibold" :style="{ color: colorForHash(selectedMember.hash) }">
+ {{ selectedMember.name }}
+ </span>
+ </div>
+ <div
+ v-if="!selectedMember"
+ class="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-sem-fg-muted"
+ >
+ <MaterialDesignIcon icon-name="account-search" class="size-10 opacity-40" />
+ {{ $t("relay_chat.host_members_select") }}
+ </div>
+ <template v-else>
+ <div :class="[RELAY_HOST_DETAIL_HEADER, 'hidden lg:block']">
+ <div class="font-semibold" :style="{ color: colorForHash(selectedMember.hash) }">
+ {{ selectedMember.name }}
+ </div>
+ <button
+ type="button"
+ class="mt-0.5 font-mono text-xs text-sem-fg-muted hover:text-sem-accent"
+ @click="copyHash(selectedMember.hash)"
+ >
+ {{ formatHash(selectedMember.hash) }}
+ </button>
+ </div>
+ <div class="min-h-0 flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
+ <div v-if="messagesLoading" class="py-8 text-center text-sm text-sem-fg-muted">
+ {{ $t("common.loading") }}
+ </div>
+ <div v-else-if="memberMessages.length === 0" class="py-8 text-center text-sm text-sem-fg-muted">
+ {{ $t("relay_chat.host_no_messages") }}
+ </div>
+ <ul v-else class="space-y-2">
+ <li v-for="(msg, idx) in memberMessages" :key="idx" :class="RELAY_HOST_MESSAGE">
+ <div class="flex flex-wrap items-center gap-x-2 text-xs text-sem-fg-muted">
+ <span>#{{ msg.room }}</span>
+ <span>{{ formatTime(msg.ts) }}</span>
+ <span v-if="msg.kind === 'action'" class="italic">action</span>
+ </div>
+ <div class="mt-1 whitespace-pre-wrap break-words">{{ msg.text }}</div>
+ </li>
+ </ul>
+ </div>
+ </template>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import DialogUtils from "../../js/DialogUtils";
+import ToastUtils from "../../js/ToastUtils";
+import {
+ RELAY_HOST_DETAIL_HEADER,
+ RELAY_HOST_ICON_BTN,
+ RELAY_HOST_LIST_ITEM,
+ RELAY_HOST_LIST_ITEM_IDLE,
+ RELAY_HOST_LIST_ITEM_SELECTED,
+ RELAY_HOST_MESSAGE,
+ RELAY_HOST_PAGE_BODY,
+ RELAY_HOST_PAGE_DETAIL,
+ RELAY_HOST_PAGE_HEADER,
+ RELAY_HOST_PAGE_LIST,
+ RELAY_HOST_PAGE_TAB,
+ RELAY_HOST_PAGE_TAB_ACTIVE,
+ RELAY_HOST_PAGE_TAB_IDLE,
+ RELAY_HOST_PAGE_TABS,
+} from "../../js/relayHostModerationClasses.js";
+
+const BTN_PRIMARY =
+ "inline-flex items-center justify-center gap-1.5 rounded-lg bg-sem-action-primary px-3 py-2 text-sm font-semibold text-white transition hover:bg-sem-action-primary-hover disabled:opacity-50";
+const BTN_SECONDARY =
+ "inline-flex items-center justify-center gap-1.5 rounded-lg border border-sem-border bg-sem-surface-raised px-3 py-2 text-sm font-medium text-sem-fg transition hover:bg-sem-surface-muted disabled:opacity-50";
+const NAME_COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#8b5cf6", "#ec4899"];
+
+export default {
+ name: "RelayHostModerationPage",
+ components: { MaterialDesignIcon },
+ props: {
+ hub: { type: Object, default: null },
+ initialTab: { type: String, default: "rooms" },
+ roomFilter: { type: String, default: null },
+ },
+ emits: ["back", "refresh"],
+ data() {
+ return {
+ RELAY_HOST_PAGE_HEADER,
+ RELAY_HOST_PAGE_TABS,
+ RELAY_HOST_PAGE_TAB,
+ RELAY_HOST_PAGE_TAB_ACTIVE,
+ RELAY_HOST_PAGE_TAB_IDLE,
+ RELAY_HOST_PAGE_BODY,
+ RELAY_HOST_PAGE_LIST,
+ RELAY_HOST_PAGE_DETAIL,
+ RELAY_HOST_ICON_BTN,
+ RELAY_HOST_LIST_ITEM,
+ RELAY_HOST_LIST_ITEM_SELECTED,
+ RELAY_HOST_LIST_ITEM_IDLE,
+ RELAY_HOST_DETAIL_HEADER,
+ RELAY_HOST_MESSAGE,
+ btnPrimary: BTN_PRIMARY,
+ btnSecondary: BTN_SECONDARY,
+ tab: "rooms",
+ isNarrow: false,
+ mq: null,
+ roomsLoading: false,
+ membersLoading: false,
+ creatingRoom: false,
+ messagesLoading: false,
+ showAddRoomForm: false,
+ rooms: [],
+ recent: [],
+ members: [],
+ roomsSearch: "",
+ membersSearch: "",
+ selectedRoom: null,
+ selectedMember: null,
+ memberMessages: [],
+ newRoom: { name: "", topic: "" },
+ localIdentityHash: "",
+ uptimeTick: 0,
+ uptimeAnchorMs: 0,
+ uptimeTimer: null,
+ };
+ },
+ computed: {
+ liveUptimeSeconds() {
+ void this.uptimeTick;
+ if (!this.hub?.running) {
+ return 0;
+ }
+ const base = Number(this.hub.uptime_seconds) || 0;
+ if (!this.uptimeAnchorMs) {
+ return base;
+ }
+ return base + Math.floor((Date.now() - this.uptimeAnchorMs) / 1000);
+ },
+ navbarMemberCount() {
+ if (this.roomFilter) {
+ if (this.membersLoading) {
+ return null;
+ }
+ return this.members.length;
+ }
+ if (this.hub?.clients != null) {
+ return this.hub.clients;
+ }
+ if (!this.membersLoading) {
+ return this.members.length;
+ }
+ return null;
+ },
+ filteredRooms() {
+ const q = this.roomsSearch.trim().toLowerCase();
+ if (!q) {
+ return this.rooms;
+ }
+ return this.rooms.filter((room) => {
+ const name = (room.name || "").toLowerCase();
+ const topic = (room.topic || "").toLowerCase();
+ return name.includes(q) || topic.includes(q);
+ });
+ },
+ pageTitle() {
+ if (!this.hub?.name) {
+ return this.$t("relay_chat.host_moderation_title");
+ }
+ if (this.roomFilter) {
+ return this.$t("relay_chat.host_moderation_title_room", {
+ hub: this.hub.name,
+ room: this.roomFilter,
+ });
+ }
+ return this.$t("relay_chat.host_moderation_title_hub", { hub: this.hub.name });
+ },
+ roomMessages() {
+ if (!this.selectedRoom) {
+ return [];
+ }
+ return this.recent.filter((m) => m.room === this.selectedRoom);
+ },
+ filteredMembers() {
+ const q = this.membersSearch.trim().toLowerCase();
+ if (!q) {
+ return this.members;
+ }
+ return this.members.filter((m) => {
+ const name = (m.name || "").toLowerCase();
+ const hash = (m.hash || "").toLowerCase();
+ const rooms = (m.rooms || []).join(" ").toLowerCase();
+ return name.includes(q) || hash.includes(q) || rooms.includes(q);
+ });
+ },
+ },
+ watch: {
+ hub: {
+ immediate: true,
+ handler() {
+ this.uptimeAnchorMs = Date.now();
+ this.reload();
+ },
+ },
+ initialTab: {
+ immediate: true,
+ handler(val) {
+ this.tab = val === "members" ? "members" : "rooms";
+ },
+ },
+ tab(val) {
+ if (val === "rooms") {
+ this.fetchActivity();
+ } else {
+ this.fetchMembers();
+ }
+ },
+ },
+ mounted() {
+ this.mq = window.matchMedia("(max-width: 1023px)");
+ this.isNarrow = this.mq.matches;
+ this.mq.addEventListener("change", this.onMq);
+ this.tab = this.initialTab === "members" ? "members" : "rooms";
+ this.ensureLocalIdentity();
+ this.uptimeAnchorMs = Date.now();
+ this.uptimeTimer = window.setInterval(() => {
+ if (this.hub?.running) {
+ this.uptimeTick += 1;
+ }
+ }, 1000);
+ if (this.hub?.id) {
+ this.reload();
+ }
+ },
+ beforeUnmount() {
+ if (this.mq) {
+ this.mq.removeEventListener("change", this.onMq);
+ }
+ if (this.uptimeTimer) {
+ clearInterval(this.uptimeTimer);
+ }
+ },
+ methods: {
+ onMq() {
+ this.isNarrow = this.mq.matches;
+ },
+ setTab(next) {
+ this.tab = next;
+ if (next === "rooms") {
+ this.selectedMember = null;
+ } else {
+ this.selectedRoom = null;
+ }
+ },
+ reload() {
+ this.selectedRoom = null;
+ this.selectedMember = null;
+ this.memberMessages = [];
+ this.newRoom = { name: "", topic: "" };
+ this.showAddRoomForm = false;
+ this.roomsSearch = "";
+ if (this.hub?.running) {
+ this.fetchMembers();
+ } else {
+ this.members = [];
+ }
+ if (this.tab === "rooms") {
+ this.fetchActivity();
+ }
+ },
+ cancelAddRoom() {
+ this.showAddRoomForm = false;
+ this.newRoom = { name: "", topic: "" };
+ },
+ async fetchActivity() {
+ if (!this.hub?.id) {
+ return;
+ }
+ this.roomsLoading = true;
+ try {
+ const response = await window.api.get(`/api/v1/rrc/servers/${this.hub.id}/activity`);
+ this.rooms = response.data?.rooms || [];
+ this.recent = response.data?.recent || [];
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
+ } finally {
+ this.roomsLoading = false;
+ }
+ },
+ async fetchMembers() {
+ if (!this.hub?.id) {
+ return;
+ }
+ this.membersLoading = true;
+ try {
+ const params = this.roomFilter ? { params: { room: this.roomFilter } } : {};
+ const response = await window.api.get(`/api/v1/rrc/servers/${this.hub.id}/members`, params);
+ this.members = response.data?.members || [];
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
+ } finally {
+ this.membersLoading = false;
+ }
+ },
+ async createRoom() {
+ const name = (this.newRoom.name || "").trim();
+ if (!name || !this.hub?.id) {
+ ToastUtils.warning(this.$t("relay_chat.room_required"));
+ return;
+ }
+ this.creatingRoom = true;
+ try {
+ await window.api.post(`/api/v1/rrc/servers/${this.hub.id}/rooms`, {
+ name,
+ topic: (this.newRoom.topic || "").trim() || undefined,
+ });
+ this.newRoom = { name: "", topic: "" };
+ this.showAddRoomForm = false;
+ ToastUtils.success(this.$t("relay_chat.host_room_created"));
+ this.$emit("refresh");
+ await this.fetchActivity();
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
+ } finally {
+ this.creatingRoom = false;
+ }
+ },
+ async deleteRoom(room) {
+ const confirmed = await DialogUtils.confirm(this.$t("relay_chat.host_delete_room_confirm"));
+ if (!confirmed || !this.hub?.id) {
+ return;
+ }
+ try {
+ await window.api.delete(`/api/v1/rrc/servers/${this.hub.id}/rooms/${encodeURIComponent(room)}`);
+ ToastUtils.success(this.$t("relay_chat.host_room_deleted"));
+ if (this.selectedRoom === room) {
+ this.selectedRoom = null;
+ }
+ this.$emit("refresh");
+ await this.fetchActivity();
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
+ }
+ },
+ selectMember(member) {
+ this.selectedMember = member;
+ this.loadMemberMessages();
+ },
+ async loadMemberMessages() {
+ if (!this.selectedMember || !this.hub?.id) {
+ return;
+ }
+ this.messagesLoading = true;
+ try {
+ const params = { peer: this.selectedMember.hash, limit: 200 };
+ if (this.roomFilter) {
+ params.room = this.roomFilter;
+ }
+ const response = await window.api.get(`/api/v1/rrc/servers/${this.hub.id}/messages`, {
+ params,
+ });
+ this.memberMessages = response.data?.messages || [];
+ } catch {
+ this.memberMessages = [];
+ } finally {
+ this.messagesLoading = false;
+ }
+ },
+ async ensureLocalIdentity() {
+ if (this.localIdentityHash) {
+ return;
+ }
+ try {
+ const response = await window.api.get("/api/v1/config");
+ const hash = response.data?.identity_hash;
+ if (typeof hash === "string" && hash.trim()) {
+ this.localIdentityHash = hash.trim().toLowerCase();
+ }
+ } catch {
+ // config may be unavailable in tests
+ }
+ },
+ async resolveModerationRoom(member, action) {
+ if (this.roomFilter) {
+ return this.roomFilter;
+ }
+ const needsRoom = action === "kick" || action === "room_ban";
+ if (!needsRoom) {
+ return null;
+ }
+ const rooms = (member.rooms || []).filter((r) => typeof r === "string" && r.trim());
+ if (rooms.length === 0) {
+ ToastUtils.warning(this.$t("relay_chat.host_kick_no_room"));
+ return null;
+ }
+ if (rooms.length === 1) {
+ return rooms[0];
+ }
+ const entered = await DialogUtils.prompt(
+ this.$t("relay_chat.host_kick_pick_room", {
+ name: member.name,
+ rooms: rooms.map((r) => "#" + r).join(", "),
+ })
+ );
+ if (!entered) {
+ return null;
+ }
+ const norm = entered.trim().replace(/^#/, "");
+ const match = rooms.find((r) => r.toLowerCase() === norm.toLowerCase());
+ if (!match) {
+ ToastUtils.warning(this.$t("relay_chat.host_kick_room_invalid"));
+ return null;
+ }
+ return match;
+ },
+ async moderate(member, action) {
+ if (!this.hub?.id || !member?.hash) {
+ return;
+ }
+ await this.ensureLocalIdentity();
+ if (this.localIdentityHash && member.hash.toLowerCase() === this.localIdentityHash) {
+ ToastUtils.warning(this.$t("relay_chat.host_cannot_moderate_self"));
+ return;
+ }
+ const room = await this.resolveModerationRoom(member, action);
+ if ((action === "kick" || action === "room_ban") && !room) {
+ return;
+ }
+ const labels = {
+ kick: this.$t("relay_chat.host_kick_confirm", { name: member.name, room }),
+ ban: this.$t("relay_chat.host_ban_confirm", { name: member.name }),
+ room_ban: this.$t("relay_chat.host_room_ban_confirm", {
+ name: member.name,
+ room,
+ }),
+ };
+ const confirmed = await DialogUtils.confirm(labels[action] || "");
+ if (!confirmed) {
+ return;
+ }
+ try {
+ await window.api.post(`/api/v1/rrc/servers/${this.hub.id}/moderate`, {
+ action,
+ peer: member.hash,
+ room: room || undefined,
+ });
+ ToastUtils.success(this.$t("common.success"));
+ this.$emit("refresh");
+ await this.fetchMembers();
+ if (this.selectedMember?.hash === member.hash) {
+ this.selectedMember = null;
+ this.memberMessages = [];
+ }
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
+ }
+ },
+ colorForHash(hash) {
+ if (!hash) {
+ return undefined;
+ }
+ let n = 0;
+ for (let i = 0; i < hash.length; i++) {
+ n = (n + hash.charCodeAt(i)) % NAME_COLORS.length;
+ }
+ return NAME_COLORS[n];
+ },
+ formatHash(hash) {
+ if (!hash || hash.length < 16) {
+ return hash || "";
+ }
+ return hash.slice(0, 8) + "..." + hash.slice(-8);
+ },
+ formatTime(ts) {
+ if (!ts) {
+ return "";
+ }
+ return new Date(ts).toLocaleString();
+ },
+ formatUptime(seconds) {
+ if (seconds == null || seconds < 0) {
+ return "—";
+ }
+ let s = Math.floor(seconds);
+ if (s < 60) {
+ return `${s}s`;
+ }
+ if (s < 3600) {
+ return `${Math.floor(s / 60)}m`;
+ }
+ if (s < 86400) {
+ return `${Math.floor(s / 3600)}h`;
+ }
+ if (s < 30 * 86400) {
+ return `${Math.floor(s / 86400)}d`;
+ }
+ const yearSec = 365 * 86400;
+ const monthSec = 30 * 86400;
+ const years = Math.floor(s / yearSec);
+ s -= years * yearSec;
+ const months = Math.floor(s / monthSec);
+ s -= months * monthSec;
+ const days = Math.floor(s / 86400);
+ const parts = [];
+ if (years) {
+ parts.push(`${years}y`);
+ }
+ if (months) {
+ parts.push(`${months}mo`);
+ }
+ if (days) {
+ parts.push(`${days}d`);
+ }
+ return parts.length ? parts.join(" ") : "0d";
+ },
+ copyHash(hash) {
+ if (!hash) {
+ return;
+ }
+ try {
+ navigator.clipboard.writeText(hash);
+ ToastUtils.success(this.$t("relay_chat.hash_copied"));
+ } catch {
+ ToastUtils.error(this.$t("common.failed_to_copy"));
+ }
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/relay/RelayHostRoomsModal.vue b/meshchatx/src/frontend/components/relay/RelayHostRoomsModal.vue
deleted file mode 100644
index 4025ac8e..00000000
--- a/meshchatx/src/frontend/components/relay/RelayHostRoomsModal.vue
+++ /dev/null
@@ -1,293 +0,0 @@
-<!-- SPDX-License-Identifier: 0BSD -->
-
-<template>
- <div
- v-if="open"
- class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
- @click.self="$emit('close')"
- >
- <div
- class="flex h-[min(100dvh-2rem,900px)] w-full max-w-6xl flex-col rounded-2xl border border-sem-border-card bg-sem-surface shadow-xl"
- role="dialog"
- :aria-label="title"
- >
- <div class="flex shrink-0 items-center gap-2 border-b border-sem-border px-4 py-3 sm:px-5 sm:py-4">
- <button
- v-if="selectedRoom && isNarrow"
- type="button"
- class="rounded-lg p-1.5 text-sem-fg-muted hover:bg-sem-surface/60"
- @click="selectedRoom = null"
- >
- <MaterialDesignIcon icon-name="arrow-left" class="size-5" />
- </button>
- <h2 class="min-w-0 flex-1 text-lg font-semibold truncate">{{ title }}</h2>
- <button
- type="button"
- class="rounded-lg p-1.5 text-sem-fg-muted hover:bg-sem-surface/60"
- :title="$t('common.close')"
- @click="$emit('close')"
- >
- <MaterialDesignIcon icon-name="close" class="size-5" />
- </button>
- </div>
-
- <div class="flex min-h-0 flex-1 flex-col lg:flex-row">
- <div
- class="flex min-h-0 flex-col border-sem-border lg:w-96 lg:shrink-0 lg:border-r"
- :class="isNarrow && selectedRoom ? 'hidden' : 'flex-1 lg:flex-none'"
- >
- <div class="shrink-0 border-b border-sem-border p-3 sm:p-4 space-y-2">
- <form class="flex flex-wrap gap-2" @submit.prevent="createRoom">
- <input
- v-model="newRoom.name"
- type="text"
- :placeholder="$t('relay_chat.host_room_name')"
- class="input-field !py-2 !text-xs flex-1 min-w-[8rem]"
- />
- <input
- v-model="newRoom.topic"
- type="text"
- :placeholder="$t('relay_chat.host_room_topic')"
- class="input-field !py-2 !text-xs flex-1 min-w-[8rem]"
- />
- <button type="submit" :class="[btnPrimary, '!px-3 !py-2 !text-xs']" :disabled="creating">
- <MaterialDesignIcon icon-name="plus" class="size-4" />
- {{ $t("relay_chat.host_add_room") }}
- </button>
- </form>
- </div>
- <div class="flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
- <div v-if="loading" class="py-12 text-center text-sm text-sem-fg-muted">
- {{ $t("common.loading") }}
- </div>
- <ul v-else class="space-y-1.5">
- <li
- v-for="room in rooms"
- :key="room.name"
- class="rounded-xl border px-3 py-2.5 cursor-pointer transition-colors"
- :class="
- selectedRoom === room.name
- ? 'border-sem-accent bg-sem-accent/10'
- : 'border-sem-border hover:bg-sem-surface/50'
- "
- @click="selectRoom(room.name)"
- >
- <div class="flex items-start justify-between gap-2">
- <div class="min-w-0">
- <div class="font-medium">#{{ room.name }}</div>
- <div v-if="room.topic" class="truncate text-xs text-sem-fg-muted">
- {{ room.topic }}
- </div>
- <div class="mt-1 flex flex-wrap gap-x-3 text-xs text-sem-fg-muted">
- <span>{{ room.members }} {{ $t("relay_chat.host_clients") }}</span>
- <span>{{ room.message_count || 0 }} msgs</span>
- </div>
- <div v-if="room.last_activity_ts" class="text-xs text-sem-fg-muted">
- {{ formatTime(room.last_activity_ts) }}
- </div>
- </div>
- <button
- v-if="room.registered"
- type="button"
- class="shrink-0 rounded-lg p-1.5 text-sem-fg-muted hover:text-sem-danger"
- :title="$t('relay_chat.host_delete_room')"
- @click.stop="deleteRoom(room.name)"
- >
- <MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
- </button>
- </div>
- </li>
- </ul>
- </div>
- </div>
-
- <div
- class="flex min-h-0 min-w-0 flex-1 flex-col"
- :class="isNarrow && !selectedRoom ? 'hidden' : 'flex'"
- >
- <div
- v-if="!selectedRoom"
- class="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-sem-fg-muted"
- >
- <MaterialDesignIcon icon-name="pound" class="size-10 opacity-40" />
- {{ $t("relay_chat.host_rooms_select") }}
- </div>
- <template v-else>
- <div class="shrink-0 border-b border-sem-border px-4 py-3 sm:px-5">
- <div class="font-semibold">#{{ selectedRoom }}</div>
- <div class="text-xs text-sem-fg-muted">{{ $t("relay_chat.host_room_activity") }}</div>
- </div>
- <div class="flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
- <ul v-if="roomMessages.length > 0" class="space-y-2">
- <li
- v-for="(msg, idx) in roomMessages"
- :key="idx"
- class="rounded-lg border border-sem-border bg-sem-canvas px-3 py-2 text-sm"
- >
- <div class="flex flex-wrap items-center gap-x-2 text-xs text-sem-fg-muted">
- <span :style="{ color: colorForHash(msg.peer) }">{{
- msg.nick || formatHash(msg.peer)
- }}</span>
- <span>{{ formatTime(msg.ts) }}</span>
- </div>
- <div class="mt-1 whitespace-pre-wrap break-words">{{ msg.text }}</div>
- </li>
- </ul>
- <div v-else class="py-8 text-center text-sm text-sem-fg-muted">
- {{ $t("relay_chat.host_no_activity") }}
- </div>
- </div>
- </template>
- </div>
- </div>
- </div>
- </div>
-</template>
-
-<script>
-import MaterialDesignIcon from "../MaterialDesignIcon.vue";
-import DialogUtils from "../../js/DialogUtils";
-import ToastUtils from "../../js/ToastUtils";
-
-const BTN_PRIMARY =
- "inline-flex items-center justify-center gap-1.5 rounded-lg bg-sem-action-primary px-3 py-2 text-sm font-semibold text-white transition hover:bg-sem-action-primary-hover disabled:opacity-50";
-const NAME_COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#8b5cf6", "#ec4899"];
-
-export default {
- name: "RelayHostRoomsModal",
- components: { MaterialDesignIcon },
- props: {
- open: { type: Boolean, default: false },
- hub: { type: Object, default: null },
- },
- emits: ["close", "refresh"],
- data() {
- return {
- loading: false,
- creating: false,
- rooms: [],
- recent: [],
- selectedRoom: null,
- newRoom: { name: "", topic: "" },
- isNarrow: false,
- mq: null,
- btnPrimary: BTN_PRIMARY,
- };
- },
- computed: {
- title() {
- return this.$t("relay_chat.host_rooms_modal_title", { hub: this.hub?.name || "" });
- },
- roomMessages() {
- if (!this.selectedRoom) {
- return [];
- }
- return this.recent.filter((m) => m.room === this.selectedRoom);
- },
- },
- watch: {
- open(val) {
- if (val) {
- this.selectedRoom = null;
- this.newRoom = { name: "", topic: "" };
- this.fetchActivity();
- }
- },
- },
- mounted() {
- this.mq = window.matchMedia("(max-width: 1023px)");
- this.isNarrow = this.mq.matches;
- this.mq.addEventListener("change", this.onMq);
- },
- beforeUnmount() {
- if (this.mq) {
- this.mq.removeEventListener("change", this.onMq);
- }
- },
- methods: {
- onMq() {
- this.isNarrow = this.mq.matches;
- },
- async fetchActivity() {
- if (!this.hub?.id) {
- return;
- }
- this.loading = true;
- try {
- const response = await window.api.get(`/api/v1/rrc/servers/${this.hub.id}/activity`);
- this.rooms = response.data?.rooms || [];
- this.recent = response.data?.recent || [];
- } catch (e) {
- ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
- this.$emit("close");
- } finally {
- this.loading = false;
- }
- },
- selectRoom(name) {
- this.selectedRoom = name;
- },
- async createRoom() {
- const name = (this.newRoom.name || "").trim();
- if (!name || !this.hub?.id) {
- ToastUtils.warning(this.$t("relay_chat.room_required"));
- return;
- }
- this.creating = true;
- try {
- await window.api.post(`/api/v1/rrc/servers/${this.hub.id}/rooms`, {
- name,
- topic: (this.newRoom.topic || "").trim() || undefined,
- });
- this.newRoom = { name: "", topic: "" };
- ToastUtils.success(this.$t("relay_chat.host_room_created"));
- this.$emit("refresh");
- await this.fetchActivity();
- } catch (e) {
- ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
- } finally {
- this.creating = false;
- }
- },
- async deleteRoom(room) {
- const confirmed = await DialogUtils.confirm(this.$t("relay_chat.host_delete_room_confirm"));
- if (!confirmed || !this.hub?.id) {
- return;
- }
- try {
- await window.api.delete(`/api/v1/rrc/servers/${this.hub.id}/rooms/${encodeURIComponent(room)}`);
- ToastUtils.success(this.$t("relay_chat.host_room_deleted"));
- if (this.selectedRoom === room) {
- this.selectedRoom = null;
- }
- this.$emit("refresh");
- await this.fetchActivity();
- } catch (e) {
- ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
- }
- },
- colorForHash(hash) {
- if (!hash) {
- return undefined;
- }
- let n = 0;
- for (let i = 0; i < hash.length; i++) {
- n = (n + hash.charCodeAt(i)) % NAME_COLORS.length;
- }
- return NAME_COLORS[n];
- },
- formatHash(hash) {
- if (!hash || hash.length < 16) {
- return hash || "";
- }
- return hash.slice(0, 8) + "..." + hash.slice(-8);
- },
- formatTime(ts) {
- if (!ts) {
- return "";
- }
- return new Date(ts).toLocaleString();
- },
- },
-};
-</script>
diff --git a/meshchatx/src/frontend/components/tools/RNSHManagerPage.vue b/meshchatx/src/frontend/components/tools/RNSHManagerPage.vue
index 048c3cbb..a333436e 100644
--- a/meshchatx/src/frontend/components/tools/RNSHManagerPage.vue
+++ b/meshchatx/src/frontend/components/tools/RNSHManagerPage.vue
@@ -3,13 +3,15 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
<ToolsPageHeader
+ v-show="!sessionFullscreen"
icon="console-network-outline"
:title="$t('rnsh.title')"
- :description="$t('rnsh.description')"
+ :description="headerDescription"
:eyebrow="$t('rnsh.remote_shell')"
accent="indigo"
/>
<div
+ v-show="!sessionFullscreen"
class="flex items-stretch h-9 shrink-0 border-b border-gray-200 dark:border-zinc-800 bg-gray-50 dark:bg-zinc-900 overflow-x-auto"
role="tablist"
>
@@ -19,7 +21,7 @@
type="button"
role="tab"
:aria-selected="activeTab === tab.id"
- class="inline-flex items-center gap-1.5 px-3 sm:px-4 border-r border-gray-200 dark:border-zinc-800 text-sm transition-colors shrink-0"
+ class="inline-flex items-center gap-1 px-2.5 sm:px-4 border-r border-gray-200 dark:border-zinc-800 text-xs sm:text-sm transition-colors shrink-0"
:class="
activeTab === tab.id
? 'bg-white dark:bg-zinc-950 text-gray-900 dark:text-gray-100 font-medium'
@@ -28,31 +30,33 @@
@click="activeTab = tab.id"
>
<MaterialDesignIcon :icon-name="tab.icon" class="size-4 shrink-0 opacity-70" />
- <span>{{ $t(tab.label) }}</span>
+ <span class="lg:hidden">{{ $t(tab.shortLabel || tab.label) }}</span>
+ <span class="hidden lg:inline">{{ $t(tab.label) }}</span>
</button>
</div>
- <div class="flex-1 flex flex-col min-h-0 overflow-hidden">
+ <div v-show="!sessionFullscreen" class="flex-1 flex flex-col min-h-0 overflow-hidden">
<div v-show="activeTab === 'sessions'" class="flex-1 flex flex-col lg:flex-row min-h-0 overflow-hidden">
<aside
- class="flex flex-col min-h-0 max-h-[40vh] lg:max-h-none lg:w-80 xl:w-96 shrink-0 border-b lg:border-b-0 lg:border-r border-gray-200 dark:border-zinc-800 px-3 md:px-4 py-3 gap-3"
+ class="flex flex-col min-h-0 shrink-0 border-gray-200 dark:border-zinc-800 px-2 sm:px-3 md:px-4 py-2 sm:py-3 gap-2 sm:gap-3"
+ :class="sessionsAsideClass"
>
<div class="flex items-center justify-between gap-2">
- <div class="text-sm font-semibold text-gray-900 dark:text-white">
+ <div class="text-xs sm:text-sm font-semibold text-gray-900 dark:text-white">
{{ $t("rnsh.sessions") }}
</div>
<button type="button" class="secondary-chip text-xs px-2 py-1.5" @click="loadSessions">
<MaterialDesignIcon icon-name="refresh" class="size-4" />
- {{ $t("rnsh.refresh") }}
+ <span class="hidden sm:inline">{{ $t("rnsh.refresh") }}</span>
</button>
</div>
- <div class="flex-1 min-h-0 space-y-1.5 overflow-y-auto custom-scrollbar pr-1">
+ <div class="flex-1 min-h-0 space-y-1 overflow-y-auto custom-scrollbar pr-0.5">
<button
v-for="session in sessions"
:key="session.id"
type="button"
- class="w-full text-left rounded-lg px-3 py-2 transition-colors"
+ class="w-full text-left rounded-lg px-2.5 sm:px-3 py-1.5 sm:py-2 transition-colors"
:class="
session.id === selectedSessionId
? 'bg-indigo-100 dark:bg-indigo-900/35 text-indigo-950 dark:text-indigo-100'
@@ -61,116 +65,57 @@
@click="selectSession(session.id)"
>
<div class="flex items-center justify-between gap-2">
- <div class="font-medium text-sm text-gray-900 dark:text-zinc-100 truncate">
+ <div class="font-medium text-xs sm:text-sm text-gray-900 dark:text-zinc-100 truncate">
{{ session.name || $t("rnsh.unnamed_session") }}
</div>
<span
- class="text-[11px] font-semibold uppercase tracking-wide shrink-0"
+ class="text-[10px] sm:text-[11px] font-semibold uppercase tracking-wide shrink-0"
:class="statusClass(session)"
>
{{ statusLabel(session) }}
</span>
</div>
- <div class="font-mono text-xs text-gray-500 dark:text-zinc-400 truncate mt-1">
+ <div
+ class="font-mono text-[10px] sm:text-xs text-gray-500 dark:text-zinc-400 truncate mt-0.5"
+ >
{{ session.mode === "listen" ? $t("rnsh.listen_mode") : session.destination || "-" }}
</div>
</button>
- <div v-if="sessions.length === 0" class="text-xs text-gray-500 dark:text-zinc-400">
+ <div v-if="sessions.length === 0" class="text-xs text-gray-500 dark:text-zinc-400 px-1">
{{ $t("rnsh.no_sessions") }}
</div>
</div>
</aside>
- <section class="flex-1 min-w-0 min-h-0 flex flex-col">
- <div
- class="shrink-0 flex flex-wrap items-center justify-between gap-2 px-3 md:px-4 py-2.5 border-b border-gray-200 dark:border-zinc-800"
- >
- <div class="min-w-0">
- <div class="text-sm font-semibold text-gray-900 dark:text-zinc-100 truncate">
- {{ selectedSession?.name || $t("rnsh.session_output") }}
- </div>
- <div class="text-xs text-gray-500 dark:text-zinc-400 font-mono truncate">
- {{ selectedSession?.last_command || $t("rnsh.no_command_yet") }}
- </div>
- </div>
- <div class="flex flex-wrap items-center gap-2">
- <button
- type="button"
- class="secondary-chip text-xs px-2 py-1.5"
- :disabled="!selectedSession"
- @click="startSelected"
- >
- <MaterialDesignIcon icon-name="play" class="size-4" />
- {{ $t("rnsh.start") }}
- </button>
- <button
- type="button"
- class="secondary-chip text-xs px-2 py-1.5 text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/40"
- :disabled="!selectedSession"
- @click="stopSelected"
- >
- <MaterialDesignIcon icon-name="stop" class="size-4" />
- {{ $t("rnsh.stop") }}
- </button>
- <button
- type="button"
- class="secondary-chip text-xs px-2 py-1.5"
- :disabled="!selectedSession"
- @click="clearSelectedOutput"
- >
- <MaterialDesignIcon icon-name="broom" class="size-4" />
- {{ $t("rnsh.clear") }}
- </button>
- <button
- type="button"
- class="secondary-chip text-xs px-2 py-1.5 text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/40"
- :disabled="!selectedSession"
- @click="removeSelected"
- >
- <MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
- {{ $t("rnsh.remove") }}
- </button>
- </div>
- </div>
-
- <div
- ref="outputBox"
- class="flex-1 min-h-0 bg-zinc-950 dark:bg-black text-zinc-100 font-mono text-xs px-3 md:px-4 py-3 whitespace-pre-wrap break-words overflow-auto custom-scrollbar"
- >
- {{ selectedOutput }}
- </div>
-
- <form
- class="shrink-0 flex flex-wrap gap-2 px-3 md:px-4 py-2.5 border-t border-gray-200 dark:border-zinc-800 bg-slate-50 dark:bg-zinc-950"
- @submit.prevent="sendCommand"
- >
- <input
- v-model="commandInput"
- type="text"
- class="input-field flex-1 min-w-52 font-mono text-xs"
- :placeholder="$t('rnsh.command_input_placeholder')"
- :disabled="!selectedSession"
- />
- <button
- type="submit"
- class="primary-chip px-3 py-2 text-xs"
- :disabled="!selectedSession || !commandInput.trim()"
- >
- <MaterialDesignIcon icon-name="send" class="size-4" />
- {{ $t("rnsh.send_line") }}
- </button>
- </form>
+ <section class="flex-1 min-w-0 min-h-0 flex flex-col" :class="terminalSectionClass">
+ <RNSHSessionTerminal
+ ref="sessionTerminal"
+ :session="selectedSession"
+ :output="selectedOutput"
+ :command-input="commandInput"
+ :show-sessions-toggle="isNarrowScreen"
+ :sessions-open="mobileSessionsOpen"
+ :compact-header="isNarrowScreen"
+ @update:command-input="commandInput = $event"
+ @send="sendCommand"
+ @start="startSelected"
+ @stop="stopSelected"
+ @clear="clearSelectedOutput"
+ @remove="removeSelected"
+ @toggle-fullscreen="toggleSessionFullscreen"
+ @toggle-sessions="toggleMobileSessions"
+ />
</section>
</div>
<div
v-show="activeTab === 'connect'"
- class="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-4 md:px-5 lg:px-8 py-4 space-y-4"
+ class="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-3 sm:px-4 md:px-5 lg:px-8 py-3 sm:py-4 space-y-3 sm:space-y-4"
>
<p class="text-xs text-gray-500 dark:text-zinc-500 leading-relaxed">
{{ $t("rnsh.usage_hint") }}
</p>
- <div class="grid lg:grid-cols-2 gap-4">
+ <div class="grid gap-3 sm:gap-4 lg:grid-cols-2">
<div>
<label class="glass-label">{{ $t("rnsh.name") }}</label>
<input
@@ -185,7 +130,7 @@
<input
v-model="connectForm.destination"
type="text"
- class="input-field font-mono"
+ class="input-field font-mono text-xs"
:placeholder="$t('rnsh.destination_placeholder')"
/>
</div>
@@ -199,17 +144,21 @@
:placeholder="$t('rnsh.command_placeholder')"
/>
</div>
- <div class="flex flex-wrap items-center gap-4">
- <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
+ <div class="flex flex-wrap items-center gap-3 sm:gap-4">
+ <label class="flex items-center gap-2 text-xs sm:text-sm text-gray-700 dark:text-gray-300">
<input v-model="connectForm.mirror" type="checkbox" class="rounded-sm" />
{{ $t("rnsh.mirror_exit_code") }}
</label>
- <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
+ <label class="flex items-center gap-2 text-xs sm:text-sm text-gray-700 dark:text-gray-300">
<input v-model="connectForm.no_id" type="checkbox" class="rounded-sm" />
{{ $t("rnsh.no_id") }}
</label>
</div>
- <button type="button" class="primary-chip px-4 py-2 text-sm" @click="createConnectSession">
+ <button
+ type="button"
+ class="primary-chip px-4 py-2 text-sm w-full sm:w-auto"
+ @click="createConnectSession"
+ >
<MaterialDesignIcon icon-name="plus" class="size-4" />
{{ $t("rnsh.create_and_start") }}
</button>
@@ -217,7 +166,7 @@
<div
v-show="activeTab === 'listen'"
- class="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-4 md:px-5 lg:px-8 py-4 space-y-4"
+ class="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-3 sm:px-4 md:px-5 lg:px-8 py-3 sm:py-4 space-y-3 sm:space-y-4"
>
<p class="text-xs text-gray-500 dark:text-zinc-500 leading-relaxed">
{{ $t("rnsh.usage_hint") }}
@@ -249,18 +198,52 @@
:placeholder="$t('rnsh.command_placeholder')"
/>
</div>
- <button type="button" class="primary-chip px-4 py-2 text-sm" @click="createListenSession">
+ <button
+ type="button"
+ class="primary-chip px-4 py-2 text-sm w-full sm:w-auto"
+ @click="createListenSession"
+ >
<MaterialDesignIcon icon-name="plus" class="size-4" />
{{ $t("rnsh.create_and_start") }}
</button>
</div>
</div>
+
+ <Teleport to="body">
+ <div
+ v-if="sessionFullscreen"
+ class="fixed inset-0 z-[220] flex flex-col bg-zinc-950"
+ role="dialog"
+ aria-modal="true"
+ :aria-label="$t('rnsh.session_output')"
+ >
+ <RNSHSessionTerminal
+ ref="fullscreenTerminal"
+ :session="selectedSession"
+ :output="selectedOutput"
+ :command-input="commandInput"
+ fullscreen
+ :show-sessions-toggle="isNarrowScreen"
+ :sessions-open="mobileSessionsOpen"
+ compact-header
+ @update:command-input="commandInput = $event"
+ @send="sendCommand"
+ @start="startSelected"
+ @stop="stopSelected"
+ @clear="clearSelectedOutput"
+ @remove="removeSelected"
+ @toggle-fullscreen="toggleSessionFullscreen"
+ @toggle-sessions="toggleMobileSessions"
+ />
+ </div>
+ </Teleport>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToolsPageHeader from "./ToolsPageHeader.vue";
+import RNSHSessionTerminal from "./RNSHSessionTerminal.vue";
import ToastUtils from "../../js/ToastUtils";
import WebSocketConnection from "../../js/WebSocketConnection";
import { loadRnshLayout, saveRnshLayout } from "../../js/browserLayoutStore";
@@ -269,18 +252,31 @@ const EMPTY_LAYOUT = {
selectedSessionId: null,
};
+const NARROW_BREAKPOINT_PX = 1024;
+
export default {
name: "RNSHManagerPage",
components: {
MaterialDesignIcon,
ToolsPageHeader,
+ RNSHSessionTerminal,
},
data() {
return {
viewTabs: [
- { id: "sessions", label: "rnsh.tab_sessions", icon: "console-line" },
- { id: "connect", label: "rnsh.tab_connect", icon: "lan-connect" },
- { id: "listen", label: "rnsh.tab_listen", icon: "access-point-network" },
+ {
+ id: "sessions",
+ label: "rnsh.tab_sessions",
+ shortLabel: "rnsh.tab_sessions_short",
+ icon: "console-line",
+ },
+ { id: "connect", label: "rnsh.tab_connect", shortLabel: "rnsh.tab_connect_short", icon: "lan-connect" },
+ {
+ id: "listen",
+ label: "rnsh.tab_listen",
+ shortLabel: "rnsh.tab_listen_short",
+ icon: "access-point-network",
+ },
],
activeTab: "sessions",
sessions: [],
@@ -299,6 +295,11 @@ export default {
allowed_hashes_text: "",
command: "",
},
+ isNarrowScreen: false,
+ mobileSessionsOpen: false,
+ sessionFullscreen: false,
+ onWindowResize: null,
+ onFullscreenKeydown: null,
};
},
computed: {
@@ -315,16 +316,77 @@ export default {
}
return this.$t("rnsh.no_output_yet");
},
+ headerDescription() {
+ return this.isNarrowScreen ? "" : this.$t("rnsh.description");
+ },
+ sessionsAsideClass() {
+ if (!this.isNarrowScreen) {
+ return "lg:w-80 xl:w-96 border-b lg:border-b-0 lg:border-r max-h-[36vh] lg:max-h-none";
+ }
+ if (this.mobileSessionsOpen) {
+ return "flex-1 min-h-0 border-b";
+ }
+ return "hidden";
+ },
+ terminalSectionClass() {
+ if (this.isNarrowScreen && this.mobileSessionsOpen) {
+ return "hidden";
+ }
+ return "";
+ },
+ },
+ watch: {
+ sessionFullscreen(active) {
+ if (typeof document === "undefined") {
+ return;
+ }
+ document.body.style.overflow = active ? "hidden" : "";
+ if (active) {
+ this.$nextTick(() => this.scrollOutputToBottom());
+ }
+ },
},
async mounted() {
+ this.updateViewport();
+ this.onWindowResize = () => this.updateViewport();
+ window.addEventListener("resize", this.onWindowResize, { passive: true });
+ this.onFullscreenKeydown = (event) => {
+ if (event.key === "Escape" && this.sessionFullscreen) {
+ this.sessionFullscreen = false;
+ }
+ };
+ window.addEventListener("keydown", this.onFullscreenKeydown);
this.restoreLayout();
await this.loadSessions();
WebSocketConnection.on("message", this.onWebsocketMessage);
},
beforeUnmount() {
+ if (this.onWindowResize) {
+ window.removeEventListener("resize", this.onWindowResize);
+ }
+ if (this.onFullscreenKeydown) {
+ window.removeEventListener("keydown", this.onFullscreenKeydown);
+ }
+ document.body.style.overflow = "";
WebSocketConnection.off("message", this.onWebsocketMessage);
},
methods: {
+ updateViewport() {
+ const narrow = typeof window !== "undefined" && window.innerWidth < NARROW_BREAKPOINT_PX;
+ this.isNarrowScreen = narrow;
+ if (!narrow) {
+ this.mobileSessionsOpen = false;
+ }
+ },
+ toggleMobileSessions() {
+ this.mobileSessionsOpen = !this.mobileSessionsOpen;
+ },
+ toggleSessionFullscreen() {
+ this.sessionFullscreen = !this.sessionFullscreen;
+ if (this.sessionFullscreen && this.isNarrowScreen) {
+ this.mobileSessionsOpen = false;
+ }
+ },
statusClass(session) {
if (!session) return "text-gray-500";
if (session.status === "running") return "text-emerald-600 dark:text-emerald-400";
@@ -348,6 +410,9 @@ export default {
selectSession(sessionId) {
this.selectedSessionId = sessionId;
this.persistLayout();
+ if (this.isNarrowScreen) {
+ this.mobileSessionsOpen = false;
+ }
this.$nextTick(() => {
this.scrollOutputToBottom();
});
@@ -526,11 +591,13 @@ export default {
}
},
scrollOutputToBottom() {
- const target = this.$refs.outputBox;
- if (!target) {
- return;
+ const inline = this.$refs.sessionTerminal;
+ const full = this.$refs.fullscreenTerminal;
+ if (this.sessionFullscreen && full?.scrollToBottom) {
+ full.scrollToBottom();
+ } else if (inline?.scrollToBottom) {
+ inline.scrollToBottom();
}
- target.scrollTop = target.scrollHeight;
},
},
};
diff --git a/meshchatx/src/frontend/components/tools/RNSHSessionTerminal.vue b/meshchatx/src/frontend/components/tools/RNSHSessionTerminal.vue
new file mode 100644
index 00000000..03d69f44
--- /dev/null
+++ b/meshchatx/src/frontend/components/tools/RNSHSessionTerminal.vue
@@ -0,0 +1,166 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div class="flex flex-col min-h-0 flex-1 min-w-0" :class="fullscreen ? 'h-dvh max-h-dvh' : ''">
+ <div
+ class="shrink-0 flex flex-wrap items-center justify-between gap-1.5 sm:gap-2 border-b border-gray-200 dark:border-zinc-800"
+ :class="fullscreen ? 'px-2 py-2 bg-zinc-900 safe-top' : 'px-2 sm:px-3 md:px-4 py-2 sm:py-2.5'"
+ >
+ <div class="min-w-0 flex-1 flex items-center gap-1.5">
+ <button
+ v-if="showSessionsToggle"
+ type="button"
+ class="secondary-chip text-xs px-2 py-1.5 shrink-0 lg:hidden"
+ :aria-label="sessionsOpen ? $t('rnsh.hide_sessions') : $t('rnsh.show_sessions')"
+ @click="$emit('toggle-sessions')"
+ >
+ <MaterialDesignIcon icon-name="format-list-bulleted" class="size-4" />
+ <span class="hidden sm:inline">{{
+ sessionsOpen ? $t("rnsh.hide_sessions") : $t("rnsh.show_sessions")
+ }}</span>
+ </button>
+ <div class="min-w-0">
+ <div class="text-xs sm:text-sm font-semibold text-gray-900 dark:text-zinc-100 truncate">
+ {{ session?.name || $t("rnsh.session_output") }}
+ </div>
+ <div
+ v-if="!compactHeader"
+ class="text-[10px] sm:text-xs text-gray-500 dark:text-zinc-400 font-mono truncate"
+ >
+ {{ session?.last_command || $t("rnsh.no_command_yet") }}
+ </div>
+ </div>
+ </div>
+ <div class="flex flex-wrap items-center gap-1 sm:gap-2 shrink-0">
+ <button
+ type="button"
+ class="secondary-chip text-xs p-1.5 sm:px-2 sm:py-1.5"
+ :disabled="!session"
+ :title="$t('rnsh.start')"
+ :aria-label="$t('rnsh.start')"
+ @click="$emit('start')"
+ >
+ <MaterialDesignIcon icon-name="play" class="size-4" />
+ <span class="hidden sm:inline ml-1">{{ $t("rnsh.start") }}</span>
+ </button>
+ <button
+ type="button"
+ class="secondary-chip text-xs p-1.5 sm:px-2 sm:py-1.5 text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/40"
+ :disabled="!session"
+ :title="$t('rnsh.stop')"
+ :aria-label="$t('rnsh.stop')"
+ @click="$emit('stop')"
+ >
+ <MaterialDesignIcon icon-name="stop" class="size-4" />
+ <span class="hidden sm:inline ml-1">{{ $t("rnsh.stop") }}</span>
+ </button>
+ <button
+ type="button"
+ class="secondary-chip text-xs p-1.5 sm:px-2 sm:py-1.5"
+ :disabled="!session"
+ :title="$t('rnsh.clear')"
+ :aria-label="$t('rnsh.clear')"
+ @click="$emit('clear')"
+ >
+ <MaterialDesignIcon icon-name="broom" class="size-4" />
+ <span class="hidden sm:inline ml-1">{{ $t("rnsh.clear") }}</span>
+ </button>
+ <button
+ type="button"
+ class="secondary-chip text-xs p-1.5 sm:px-2 sm:py-1.5 text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/40"
+ :disabled="!session"
+ :title="$t('rnsh.remove')"
+ :aria-label="$t('rnsh.remove')"
+ @click="$emit('remove')"
+ >
+ <MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
+ <span class="hidden sm:inline ml-1">{{ $t("rnsh.remove") }}</span>
+ </button>
+ <button
+ type="button"
+ class="secondary-chip text-xs p-1.5 sm:px-2 sm:py-1.5"
+ :title="fullscreen ? $t('rnsh.exit_fullscreen') : $t('rnsh.fullscreen')"
+ :aria-label="fullscreen ? $t('rnsh.exit_fullscreen') : $t('rnsh.fullscreen')"
+ @click="$emit('toggle-fullscreen')"
+ >
+ <MaterialDesignIcon :icon-name="fullscreen ? 'fullscreen-exit' : 'fullscreen'" class="size-4" />
+ </button>
+ </div>
+ </div>
+
+ <div
+ ref="outputBox"
+ class="flex-1 min-h-0 bg-zinc-950 dark:bg-black text-zinc-100 font-mono whitespace-pre-wrap break-words overflow-auto custom-scrollbar"
+ :class="fullscreen ? 'text-[11px] leading-relaxed px-2 py-2' : 'text-xs px-2 sm:px-3 md:px-4 py-2 sm:py-3'"
+ >
+ {{ output }}
+ </div>
+
+ <form
+ class="shrink-0 flex gap-1.5 sm:gap-2 border-t border-gray-200 dark:border-zinc-800 bg-slate-50 dark:bg-zinc-950"
+ :class="
+ fullscreen
+ ? 'px-2 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] safe-bottom'
+ : 'px-2 sm:px-3 md:px-4 py-2 sm:py-2.5'
+ "
+ @submit.prevent="$emit('send')"
+ >
+ <input
+ :value="commandInput"
+ type="text"
+ class="input-field flex-1 min-w-0 font-mono text-xs"
+ :placeholder="$t('rnsh.command_input_placeholder')"
+ :disabled="!session"
+ autocomplete="off"
+ autocapitalize="off"
+ spellcheck="false"
+ @input="$emit('update:commandInput', $event.target.value)"
+ />
+ <button
+ type="submit"
+ class="primary-chip px-2.5 sm:px-3 py-2 text-xs shrink-0"
+ :disabled="!session || !commandInput.trim()"
+ :aria-label="$t('rnsh.send_line')"
+ >
+ <MaterialDesignIcon icon-name="send" class="size-4" />
+ <span class="hidden sm:inline ml-1">{{ $t("rnsh.send_line") }}</span>
+ </button>
+ </form>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+
+export default {
+ name: "RNSHSessionTerminal",
+ components: { MaterialDesignIcon },
+ props: {
+ session: { type: Object, default: null },
+ output: { type: String, required: true },
+ commandInput: { type: String, default: "" },
+ fullscreen: { type: Boolean, default: false },
+ showSessionsToggle: { type: Boolean, default: false },
+ sessionsOpen: { type: Boolean, default: false },
+ compactHeader: { type: Boolean, default: false },
+ },
+ emits: ["update:commandInput", "send", "start", "stop", "clear", "remove", "toggle-fullscreen", "toggle-sessions"],
+ methods: {
+ scrollToBottom() {
+ const target = this.$refs.outputBox;
+ if (target) {
+ target.scrollTop = target.scrollHeight;
+ }
+ },
+ },
+};
+</script>
+
+<style scoped>
+.safe-top {
+ padding-top: max(0.5rem, env(safe-area-inset-top));
+}
+.safe-bottom {
+ padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
+}
+</style>
diff --git a/meshchatx/src/frontend/js/relayHostModalClasses.js b/meshchatx/src/frontend/js/relayHostModalClasses.js
new file mode 100644
index 00000000..a4fca4ac
--- /dev/null
+++ b/meshchatx/src/frontend/js/relayHostModalClasses.js
@@ -0,0 +1,9 @@
+// SPDX-License-Identifier: 0BSD
+
+/** Create-hub dialog overlay and panel (compact modal). */
+
+export const RELAY_HOST_MODAL_OVERLAY =
+ "fixed inset-0 z-[60] flex flex-col overflow-hidden bg-black/50 backdrop-blur-[2px] dark:bg-black/70 p-3 sm:items-center sm:justify-center sm:p-4 pt-[max(0.75rem,env(safe-area-inset-top))] pb-[max(0.75rem,env(safe-area-inset-bottom))]";
+
+export const RELAY_HOST_MODAL_PANEL_COMPACT =
+ "mx-auto flex w-full max-w-md shrink-0 flex-col overflow-hidden rounded-2xl border border-sem-border-card bg-sem-canvas p-5 text-sem-fg shadow-2xl ring-1 ring-inset ring-sem-border/25";
diff --git a/meshchatx/src/frontend/js/relayHostModerationClasses.js b/meshchatx/src/frontend/js/relayHostModerationClasses.js
new file mode 100644
index 00000000..250f7f85
--- /dev/null
+++ b/meshchatx/src/frontend/js/relayHostModerationClasses.js
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: 0BSD
+
+/** Relay Chat host moderation page layout (full-height, no modal shell). */
+
+export const RELAY_HOST_PAGE_HEADER =
+ "flex shrink-0 flex-wrap items-center gap-2 border-b border-sem-border bg-sem-canvas px-3 py-2.5 sm:px-4";
+
+export const RELAY_HOST_PAGE_TABS = "flex shrink-0 gap-1.5 border-b border-sem-border bg-sem-canvas px-3 py-2 sm:px-4";
+
+export const RELAY_HOST_PAGE_TAB =
+ "inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors";
+
+export const RELAY_HOST_PAGE_TAB_ACTIVE = "bg-sem-surface-raised text-sem-fg shadow-sm ring-1 ring-sem-border";
+
+export const RELAY_HOST_PAGE_TAB_IDLE = "text-sem-fg-muted hover:bg-sem-surface-raised/60 hover:text-sem-fg";
+
+export const RELAY_HOST_PAGE_BODY = "flex min-h-0 flex-1 flex-col overflow-hidden lg:flex-row";
+
+export const RELAY_HOST_PAGE_LIST =
+ "flex min-h-0 flex-col border-sem-border lg:w-80 lg:shrink-0 lg:border-r lg:bg-sem-surface-muted/20";
+
+export const RELAY_HOST_PAGE_DETAIL = "flex min-h-0 min-w-0 flex-1 flex-col bg-sem-canvas";
+
+export const RELAY_HOST_LIST_ITEM = "rounded-xl border border-sem-border bg-sem-surface-raised/50 transition-colors";
+
+export const RELAY_HOST_LIST_ITEM_SELECTED = "border-sem-accent bg-sem-accent/15";
+
+export const RELAY_HOST_LIST_ITEM_IDLE = "hover:bg-sem-surface-raised";
+
+export const RELAY_HOST_DETAIL_HEADER =
+ "shrink-0 border-b border-sem-border bg-sem-surface-muted/30 px-3 py-2.5 sm:px-4";
+
+export const RELAY_HOST_MESSAGE =
+ "rounded-lg border border-sem-border bg-sem-surface-raised px-3 py-2 text-sm text-sem-fg";
+
+export const RELAY_HOST_ICON_BTN =
+ "rounded-lg p-1.5 text-sem-fg-muted transition-colors hover:bg-sem-surface-raised hover:text-sem-fg";
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 2eb70d88..6f07fbb0 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -2162,7 +2162,14 @@
"failed_to_stop_session": "Failed to stop RNSH session",
"failed_to_remove_session": "Failed to remove RNSH session",
"failed_to_clear_output": "Failed to clear RNSH output",
- "failed_to_send_input": "Failed to send RNSH input"
+ "failed_to_send_input": "Failed to send RNSH input",
+ "tab_sessions_short": "Sessions",
+ "tab_connect_short": "Connect",
+ "tab_listen_short": "Listen",
+ "fullscreen": "Full screen",
+ "exit_fullscreen": "Exit full screen",
+ "show_sessions": "Sessions",
+ "hide_sessions": "Back to terminal"
},
"rnprobe": {
"network_diagnostics": "Netzwerkdiagnose",
@@ -2832,6 +2839,9 @@
"copy_hash": "Ziel-Hash kopieren",
"hash_copied": "Ziel-Hash kopiert",
"host_start": "Starten",
+ "host_status_running": "Läuft",
+ "host_status_stopped": "Gestoppt",
+ "host_hub_not_running": "Starten Sie den Hub, bevor Sie Räume oder Mitglieder verwalten.",
"host_stop": "Stoppen",
"host_announce": "Jetzt ankündigen",
"host_announced": "Angekündigt",
@@ -2853,8 +2863,8 @@
"host_room_created": "Raum erstellt",
"host_delete_room_confirm": "Diesen Raum löschen?",
"host_room_deleted": "Raum gelöscht",
- "host_members_all_title": "Verbundene Clients — {hub}",
- "host_members_room_title": "#{room} — {hub}",
+ "host_members_all_title": "Verbundene Clients: {hub}",
+ "host_members_room_title": "#{room}: {hub}",
"host_member_rooms": "Räume",
"hub_icon": "Hub-Symbol",
"hub_icon_choose": "Symbol wählen",
@@ -2883,7 +2893,12 @@
"show_members": "Mitglieder anzeigen",
"hide_members": "Mitglieder ausblenden",
"discovery_refreshed": "Entdeckung aktualisiert",
- "host_members_search": "Mitglieder nach Name, Hash oder Raum suchen...",
+ "host_members_search": "Mitglieder suchen...",
+ "host_rooms_search": "Räume suchen...",
+ "host_rooms_search_empty": "Keine Räume passen zur Suche.",
+ "host_no_rooms": "Noch keine Räume.",
+ "host_moderation_uptime": "{time} Laufzeit",
+ "host_moderation_members": "{count} Mitglieder",
"host_members_select": "Mitglied auswählen, um Nachrichten anzuzeigen und zu moderieren",
"host_no_messages": "Noch keine Nachrichten von diesem Benutzer im Protokoll",
"host_ban_hub": "Vom Hub sperren",
@@ -2895,7 +2910,14 @@
"host_ban_confirm": "{name} von diesem Hub sperren?",
"host_room_ban_confirm": "{name} aus #{room} sperren?",
"host_manage_rooms": "Räume verwalten",
- "host_rooms_modal_title": "Räume — {hub}",
+ "host_moderate": "Hub moderieren",
+ "host_moderation_title": "Moderation",
+ "host_moderation_title_hub": "Moderation: {hub}",
+ "host_moderation_title_room": "Moderation: {hub} (#{room})",
+ "host_moderation_tab_rooms": "Räume",
+ "host_moderation_tab_members": "Mitglieder",
+ "host_moderation_hub_missing": "Hub nicht gefunden.",
+ "host_rooms_modal_title": "Räume: {hub}",
"host_rooms_select": "Raum auswählen, um Aktivität anzuzeigen",
"host_room_activity": "Letzte Aktivität",
"host_no_activity": "Keine kürzliche Aktivität in diesem Raum",
@@ -2926,5 +2948,22 @@
"search_no_results": "Keine Nachrichten entsprechen der Suche",
"popout_channel": "In neuem Fenster öffnen",
"new_message_toast": "Neue Nachricht in #{room}"
+ },
+ "android_storage": {
+ "setup_title": "Choose where MeshChatX stores data",
+ "setup_desc": "File-manager storage is recommended. You can browse and back up your identity and messages under Android/data/com.meshchatx/files/. The app restarts when you change this during setup.",
+ "setup_external_title": "File-manager storage (recommended)",
+ "setup_external_desc": "Android/data/com.meshchatx/files/meshchatx — visible in many file managers.",
+ "setup_internal_title": "Private app storage",
+ "setup_internal_desc": "Hidden from file managers; only this app can access it.",
+ "setup_continue": "Continue",
+ "upgrade_title": "Move data to file-manager storage?",
+ "upgrade_desc": "Your MeshChatX data is in private app storage. Copy it to the file-manager folder so you can browse and back up files, then restart the app.",
+ "upgrade_copy": "Copy and restart",
+ "upgrade_stay_internal": "Stay on private storage",
+ "working": "Preparing…",
+ "copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
+ "restart_to_apply": "Restart the app to apply your storage choice.",
+ "failed": "Could not update storage location."
}
}
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 95d05f04..0d21fed7 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -2210,6 +2210,9 @@
"copy_hash": "Copy destination hash",
"hash_copied": "Destination hash copied",
"host_start": "Start",
+ "host_status_running": "Running",
+ "host_status_stopped": "Stopped",
+ "host_hub_not_running": "Start the hub before managing rooms or members.",
"host_stop": "Stop",
"host_announce": "Announce now",
"host_announced": "Announced",
@@ -2231,10 +2234,15 @@
"host_room_created": "Room created",
"host_delete_room_confirm": "Delete this room?",
"host_room_deleted": "Room deleted",
- "host_members_all_title": "Connected clients — {hub}",
- "host_members_room_title": "#{room} — {hub}",
+ "host_members_all_title": "Connected clients: {hub}",
+ "host_members_room_title": "#{room}: {hub}",
"host_member_rooms": "Rooms",
- "host_members_search": "Search members by name, hash, or room...",
+ "host_members_search": "Search members...",
+ "host_rooms_search": "Search rooms...",
+ "host_rooms_search_empty": "No rooms match your search.",
+ "host_no_rooms": "No rooms yet.",
+ "host_moderation_uptime": "{time} uptime",
+ "host_moderation_members": "{count} members",
"host_members_select": "Select a member to view messages and moderate",
"host_no_messages": "No messages from this user in the log yet",
"host_ban_hub": "Ban from hub",
@@ -2246,7 +2254,14 @@
"host_ban_confirm": "Ban {name} from this hub?",
"host_room_ban_confirm": "Ban {name} from #{room}?",
"host_manage_rooms": "Manage rooms",
- "host_rooms_modal_title": "Rooms — {hub}",
+ "host_moderate": "Moderate hub",
+ "host_moderation_title": "Moderation",
+ "host_moderation_title_hub": "Moderation: {hub}",
+ "host_moderation_title_room": "Moderation: {hub} (#{room})",
+ "host_moderation_tab_rooms": "Rooms",
+ "host_moderation_tab_members": "Members",
+ "host_moderation_hub_missing": "Hub not found.",
+ "host_rooms_modal_title": "Rooms: {hub}",
"host_rooms_select": "Select a room to view activity",
"host_room_activity": "Recent activity",
"host_no_activity": "No recent activity in this room",
@@ -2361,8 +2376,15 @@
"usage_title": "Usage",
"usage_hint": "Create connect or listen sessions, then manage them on the Sessions tab. Output streams live while MeshChatX is running.",
"tab_sessions": "Sessions",
+ "tab_sessions_short": "Sessions",
"tab_connect": "Connect",
+ "tab_connect_short": "Connect",
"tab_listen": "Listen",
+ "tab_listen_short": "Listen",
+ "fullscreen": "Full screen",
+ "exit_fullscreen": "Exit full screen",
+ "show_sessions": "Sessions",
+ "hide_sessions": "Back to terminal",
"sessions": "Sessions",
"refresh": "Refresh",
"name": "Session name",
@@ -2721,6 +2743,23 @@
"contact_updated": "Contact updated",
"failed_update_contact": "Failed to update contact"
},
+ "android_storage": {
+ "setup_title": "Choose where MeshChatX stores data",
+ "setup_desc": "File-manager storage is recommended. You can browse and back up your identity and messages under Android/data/com.meshchatx/files/. The app restarts when you change this during setup.",
+ "setup_external_title": "File-manager storage (recommended)",
+ "setup_external_desc": "Android/data/com.meshchatx/files/meshchatx — visible in many file managers.",
+ "setup_internal_title": "Private app storage",
+ "setup_internal_desc": "Hidden from file managers; only this app can access it.",
+ "setup_continue": "Continue",
+ "upgrade_title": "Move data to file-manager storage?",
+ "upgrade_desc": "Your MeshChatX data is in private app storage. Copy it to the file-manager folder so you can browse and back up files, then restart the app.",
+ "upgrade_copy": "Copy and restart",
+ "upgrade_stay_internal": "Stay on private storage",
+ "working": "Preparing…",
+ "copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
+ "restart_to_apply": "Restart the app to apply your storage choice.",
+ "failed": "Could not update storage location."
+ },
"tutorial": {
"title": "Getting Started",
"welcome": "Welcome to",
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 22807184..4eb3bc51 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -2251,7 +2251,14 @@
"failed_to_stop_session": "Failed to stop RNSH session",
"failed_to_remove_session": "Failed to remove RNSH session",
"failed_to_clear_output": "Failed to clear RNSH output",
- "failed_to_send_input": "Failed to send RNSH input"
+ "failed_to_send_input": "Failed to send RNSH input",
+ "tab_sessions_short": "Sessions",
+ "tab_connect_short": "Connect",
+ "tab_listen_short": "Listen",
+ "fullscreen": "Full screen",
+ "exit_fullscreen": "Exit full screen",
+ "show_sessions": "Sessions",
+ "hide_sessions": "Back to terminal"
},
"rnprobe": {
"network_diagnostics": "Diagnósticos de red",
@@ -2832,6 +2839,9 @@
"copy_hash": "Copiar hash de destino",
"hash_copied": "Hash de destino copiado",
"host_start": "Iniciar",
+ "host_status_running": "En ejecución",
+ "host_status_stopped": "Detenido",
+ "host_hub_not_running": "Inicie el hub antes de administrar salas o miembros.",
"host_stop": "Detener",
"host_announce": "Anunciar ahora",
"host_announced": "Anunciado",
@@ -2853,8 +2863,8 @@
"host_room_created": "Sala creada",
"host_delete_room_confirm": "¿Eliminar esta sala?",
"host_room_deleted": "Sala eliminada",
- "host_members_all_title": "Clientes conectados — {hub}",
- "host_members_room_title": "#{room} — {hub}",
+ "host_members_all_title": "Clientes conectados: {hub}",
+ "host_members_room_title": "#{room}: {hub}",
"host_member_rooms": "Salas",
"hub_icon": "Icono del hub",
"hub_icon_choose": "Elegir icono",
@@ -2883,7 +2893,12 @@
"show_members": "Mostrar miembros",
"hide_members": "Ocultar miembros",
"discovery_refreshed": "Descubrimiento actualizado",
- "host_members_search": "Buscar miembros por nombre, hash o sala...",
+ "host_members_search": "Buscar miembros...",
+ "host_rooms_search": "Buscar salas...",
+ "host_rooms_search_empty": "Ninguna sala coincide con la búsqueda.",
+ "host_no_rooms": "Aún no hay salas.",
+ "host_moderation_uptime": "{time} en ejecución",
+ "host_moderation_members": "{count} miembros",
"host_members_select": "Selecciona un miembro para ver mensajes y moderar",
"host_no_messages": "Aún no hay mensajes de este usuario en el registro",
"host_ban_hub": "Prohibir en el hub",
@@ -2895,7 +2910,14 @@
"host_ban_confirm": "¿Prohibir a {name} en este hub?",
"host_room_ban_confirm": "¿Prohibir a {name} en #{room}?",
"host_manage_rooms": "Gestionar salas",
- "host_rooms_modal_title": "Salas — {hub}",
+ "host_moderate": "Moderar hub",
+ "host_moderation_title": "Moderación",
+ "host_moderation_title_hub": "Moderación: {hub}",
+ "host_moderation_title_room": "Moderación: {hub} (#{room})",
+ "host_moderation_tab_rooms": "Salas",
+ "host_moderation_tab_members": "Miembros",
+ "host_moderation_hub_missing": "Hub no encontrado.",
+ "host_rooms_modal_title": "Salas: {hub}",
"host_rooms_select": "Selecciona una sala para ver la actividad",
"host_room_activity": "Actividad reciente",
"host_no_activity": "No hay actividad reciente en esta sala",
@@ -2926,5 +2948,22 @@
"search_no_results": "Ningún mensaje coincide con la búsqueda",
"popout_channel": "Abrir en una ventana nueva",
"new_message_toast": "Nuevo mensaje en #{room}"
+ },
+ "android_storage": {
+ "setup_title": "Choose where MeshChatX stores data",
+ "setup_desc": "File-manager storage is recommended. You can browse and back up your identity and messages under Android/data/com.meshchatx/files/. The app restarts when you change this during setup.",
+ "setup_external_title": "File-manager storage (recommended)",
+ "setup_external_desc": "Android/data/com.meshchatx/files/meshchatx — visible in many file managers.",
+ "setup_internal_title": "Private app storage",
+ "setup_internal_desc": "Hidden from file managers; only this app can access it.",
+ "setup_continue": "Continue",
+ "upgrade_title": "Move data to file-manager storage?",
+ "upgrade_desc": "Your MeshChatX data is in private app storage. Copy it to the file-manager folder so you can browse and back up files, then restart the app.",
+ "upgrade_copy": "Copy and restart",
+ "upgrade_stay_internal": "Stay on private storage",
+ "working": "Preparing…",
+ "copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
+ "restart_to_apply": "Restart the app to apply your storage choice.",
+ "failed": "Could not update storage location."
}
}
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 976855db..97e67a19 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -2251,7 +2251,14 @@
"failed_to_stop_session": "Failed to stop RNSH session",
"failed_to_remove_session": "Failed to remove RNSH session",
"failed_to_clear_output": "Failed to clear RNSH output",
- "failed_to_send_input": "Failed to send RNSH input"
+ "failed_to_send_input": "Failed to send RNSH input",
+ "tab_sessions_short": "Sessions",
+ "tab_connect_short": "Connect",
+ "tab_listen_short": "Listen",
+ "fullscreen": "Full screen",
+ "exit_fullscreen": "Exit full screen",
+ "show_sessions": "Sessions",
+ "hide_sessions": "Back to terminal"
},
"rnprobe": {
"network_diagnostics": "Diagnostics réseau",
@@ -2832,6 +2839,9 @@
"copy_hash": "Copier le hash de destination",
"hash_copied": "Hash de destination copié",
"host_start": "Démarrer",
+ "host_status_running": "En cours",
+ "host_status_stopped": "Arrêté",
+ "host_hub_not_running": "Démarrez le hub avant de gérer les salons ou les membres.",
"host_stop": "Arrêter",
"host_announce": "Annoncer maintenant",
"host_announced": "Annoncé",
@@ -2853,8 +2863,8 @@
"host_room_created": "Salon créé",
"host_delete_room_confirm": "Supprimer ce salon ?",
"host_room_deleted": "Salon supprimé",
- "host_members_all_title": "Clients connectés — {hub}",
- "host_members_room_title": "#{room} — {hub}",
+ "host_members_all_title": "Clients connectés : {hub}",
+ "host_members_room_title": "#{room} : {hub}",
"host_member_rooms": "Salons",
"hub_icon": "Icône du hub",
"hub_icon_choose": "Choisir une icône",
@@ -2883,7 +2893,12 @@
"show_members": "Afficher les membres",
"hide_members": "Masquer les membres",
"discovery_refreshed": "Découverte actualisée",
- "host_members_search": "Rechercher des membres par nom, hash ou salon...",
+ "host_members_search": "Rechercher des membres...",
+ "host_rooms_search": "Rechercher des salons...",
+ "host_rooms_search_empty": "Aucun salon ne correspond à la recherche.",
+ "host_no_rooms": "Aucun salon pour l'instant.",
+ "host_moderation_uptime": "{time} de disponibilité",
+ "host_moderation_members": "{count} membres",
"host_members_select": "Sélectionnez un membre pour voir les messages et modérer",
"host_no_messages": "Aucun message de cet utilisateur dans le journal pour l'instant",
"host_ban_hub": "Bannir du hub",
@@ -2895,7 +2910,14 @@
"host_ban_confirm": "Bannir {name} de ce hub ?",
"host_room_ban_confirm": "Bannir {name} de #{room} ?",
"host_manage_rooms": "Gérer les salons",
- "host_rooms_modal_title": "Salons — {hub}",
+ "host_moderate": "Modérer le hub",
+ "host_moderation_title": "Modération",
+ "host_moderation_title_hub": "Modération : {hub}",
+ "host_moderation_title_room": "Modération : {hub} (#{room})",
+ "host_moderation_tab_rooms": "Salons",
+ "host_moderation_tab_members": "Membres",
+ "host_moderation_hub_missing": "Hub introuvable.",
+ "host_rooms_modal_title": "Salons : {hub}",
"host_rooms_select": "Sélectionnez un salon pour voir l'activité",
"host_room_activity": "Activité récente",
"host_no_activity": "Aucune activité récente dans ce salon",
@@ -2926,5 +2948,22 @@
"search_no_results": "Aucun message ne correspond à la recherche",
"popout_channel": "Ouvrir dans une nouvelle fenêtre",
"new_message_toast": "Nouveau message dans #{room}"
+ },
+ "android_storage": {
+ "setup_title": "Choose where MeshChatX stores data",
+ "setup_desc": "File-manager storage is recommended. You can browse and back up your identity and messages under Android/data/com.meshchatx/files/. The app restarts when you change this during setup.",
+ "setup_external_title": "File-manager storage (recommended)",
+ "setup_external_desc": "Android/data/com.meshchatx/files/meshchatx — visible in many file managers.",
+ "setup_internal_title": "Private app storage",
+ "setup_internal_desc": "Hidden from file managers; only this app can access it.",
+ "setup_continue": "Continue",
+ "upgrade_title": "Move data to file-manager storage?",
+ "upgrade_desc": "Your MeshChatX data is in private app storage. Copy it to the file-manager folder so you can browse and back up files, then restart the app.",
+ "upgrade_copy": "Copy and restart",
+ "upgrade_stay_internal": "Stay on private storage",
+ "working": "Preparing…",
+ "copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
+ "restart_to_apply": "Restart the app to apply your storage choice.",
+ "failed": "Could not update storage location."
}
}
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 52e776cd..d38ece22 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -2303,7 +2303,14 @@
"failed_to_stop_session": "Failed to stop RNSH session",
"failed_to_remove_session": "Failed to remove RNSH session",
"failed_to_clear_output": "Failed to clear RNSH output",
- "failed_to_send_input": "Failed to send RNSH input"
+ "failed_to_send_input": "Failed to send RNSH input",
+ "tab_sessions_short": "Sessions",
+ "tab_connect_short": "Connect",
+ "tab_listen_short": "Listen",
+ "fullscreen": "Full screen",
+ "exit_fullscreen": "Exit full screen",
+ "show_sessions": "Sessions",
+ "hide_sessions": "Back to terminal"
},
"rnprobe": {
"network_diagnostics": "Diagnostica di Rete",
@@ -2832,6 +2839,9 @@
"copy_hash": "Copia hash di destinazione",
"hash_copied": "Hash di destinazione copiato",
"host_start": "Avvia",
+ "host_status_running": "In esecuzione",
+ "host_status_stopped": "Arrestato",
+ "host_hub_not_running": "Avvia l'hub prima di gestire stanze o membri.",
"host_stop": "Ferma",
"host_announce": "Annuncia ora",
"host_announced": "Annunciato",
@@ -2853,8 +2863,8 @@
"host_room_created": "Stanza creata",
"host_delete_room_confirm": "Eliminare questa stanza?",
"host_room_deleted": "Stanza eliminata",
- "host_members_all_title": "Client connessi — {hub}",
- "host_members_room_title": "#{room} — {hub}",
+ "host_members_all_title": "Client connessi: {hub}",
+ "host_members_room_title": "#{room}: {hub}",
"host_member_rooms": "Stanze",
"hub_icon": "Icona hub",
"hub_icon_choose": "Scegli icona",
@@ -2883,7 +2893,12 @@
"show_members": "Mostra membri",
"hide_members": "Nascondi membri",
"discovery_refreshed": "Scoperta aggiornata",
- "host_members_search": "Cerca membri per nome, hash o stanza...",
+ "host_members_search": "Cerca membri...",
+ "host_rooms_search": "Cerca stanze...",
+ "host_rooms_search_empty": "Nessuna stanza corrisponde alla ricerca.",
+ "host_no_rooms": "Nessuna stanza ancora.",
+ "host_moderation_uptime": "{time} di attività",
+ "host_moderation_members": "{count} membri",
"host_members_select": "Seleziona un membro per vedere i messaggi e moderare",
"host_no_messages": "Nessun messaggio da questo utente nel registro",
"host_ban_hub": "Banna dall'hub",
@@ -2895,7 +2910,14 @@
"host_ban_confirm": "Bannare {name} da questo hub?",
"host_room_ban_confirm": "Bannare {name} da #{room}?",
"host_manage_rooms": "Gestisci stanze",
- "host_rooms_modal_title": "Stanze — {hub}",
+ "host_moderate": "Modera hub",
+ "host_moderation_title": "Moderazione",
+ "host_moderation_title_hub": "Moderazione: {hub}",
+ "host_moderation_title_room": "Moderazione: {hub} (#{room})",
+ "host_moderation_tab_rooms": "Stanze",
+ "host_moderation_tab_members": "Membri",
+ "host_moderation_hub_missing": "Hub non trovato.",
+ "host_rooms_modal_title": "Stanze: {hub}",
"host_rooms_select": "Seleziona una stanza per vedere l'attività",
"host_room_activity": "Attività recente",
"host_no_activity": "Nessuna attività recente in questa stanza",
@@ -2926,5 +2948,22 @@
"search_no_results": "Nessun messaggio corrisponde alla ricerca",
"popout_channel": "Apri in una nuova finestra",
"new_message_toast": "Nuovo messaggio in #{room}"
+ },
+ "android_storage": {
+ "setup_title": "Choose where MeshChatX stores data",
+ "setup_desc": "File-manager storage is recommended. You can browse and back up your identity and messages under Android/data/com.meshchatx/files/. The app restarts when you change this during setup.",
+ "setup_external_title": "File-manager storage (recommended)",
+ "setup_external_desc": "Android/data/com.meshchatx/files/meshchatx — visible in many file managers.",
+ "setup_internal_title": "Private app storage",
+ "setup_internal_desc": "Hidden from file managers; only this app can access it.",
+ "setup_continue": "Continue",
+ "upgrade_title": "Move data to file-manager storage?",
+ "upgrade_desc": "Your MeshChatX data is in private app storage. Copy it to the file-manager folder so you can browse and back up files, then restart the app.",
+ "upgrade_copy": "Copy and restart",
+ "upgrade_stay_internal": "Stay on private storage",
+ "working": "Preparing…",
+ "copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
+ "restart_to_apply": "Restart the app to apply your storage choice.",
+ "failed": "Could not update storage location."
}
}
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 19b7d022..daa815e4 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -2251,7 +2251,14 @@
"failed_to_stop_session": "Failed to stop RNSH session",
"failed_to_remove_session": "Failed to remove RNSH session",
"failed_to_clear_output": "Failed to clear RNSH output",
- "failed_to_send_input": "Failed to send RNSH input"
+ "failed_to_send_input": "Failed to send RNSH input",
+ "tab_sessions_short": "Sessions",
+ "tab_connect_short": "Connect",
+ "tab_listen_short": "Listen",
+ "fullscreen": "Full screen",
+ "exit_fullscreen": "Exit full screen",
+ "show_sessions": "Sessions",
+ "hide_sessions": "Back to terminal"
},
"rnprobe": {
"network_diagnostics": "Netwerkdiagnostiek",
@@ -2832,6 +2839,9 @@
"copy_hash": "Bestemmingshash kopiëren",
"hash_copied": "Bestemmingshash gekopieerd",
"host_start": "Starten",
+ "host_status_running": "Actief",
+ "host_status_stopped": "Gestopt",
+ "host_hub_not_running": "Start de hub voordat je kamers of leden beheert.",
"host_stop": "Stoppen",
"host_announce": "Nu aankondigen",
"host_announced": "Aangekondigd",
@@ -2853,8 +2863,8 @@
"host_room_created": "Kamer gemaakt",
"host_delete_room_confirm": "Deze kamer verwijderen?",
"host_room_deleted": "Kamer verwijderd",
- "host_members_all_title": "Verbonden clients — {hub}",
- "host_members_room_title": "#{room} — {hub}",
+ "host_members_all_title": "Verbonden clients: {hub}",
+ "host_members_room_title": "#{room}: {hub}",
"host_member_rooms": "Kamers",
"hub_icon": "Hub-pictogram",
"hub_icon_choose": "Pictogram kiezen",
@@ -2883,7 +2893,12 @@
"show_members": "Leden tonen",
"hide_members": "Leden verbergen",
"discovery_refreshed": "Ontdekking vernieuwd",
- "host_members_search": "Zoek leden op naam, hash of kamer...",
+ "host_members_search": "Leden zoeken...",
+ "host_rooms_search": "Kamers zoeken...",
+ "host_rooms_search_empty": "Geen kamers komen overeen met je zoekopdracht.",
+ "host_no_rooms": "Nog geen kamers.",
+ "host_moderation_uptime": "{time} uptime",
+ "host_moderation_members": "{count} leden",
"host_members_select": "Selecteer een lid om berichten te bekijken en te modereren",
"host_no_messages": "Nog geen berichten van deze gebruiker in het logboek",
"host_ban_hub": "Verbannen van hub",
@@ -2895,7 +2910,14 @@
"host_ban_confirm": "{name} van deze hub verbannen?",
"host_room_ban_confirm": "{name} uit #{room} verbannen?",
"host_manage_rooms": "Kamers beheren",
- "host_rooms_modal_title": "Kamers — {hub}",
+ "host_moderate": "Hub modereren",
+ "host_moderation_title": "Moderatie",
+ "host_moderation_title_hub": "Moderatie: {hub}",
+ "host_moderation_title_room": "Moderatie: {hub} (#{room})",
+ "host_moderation_tab_rooms": "Kamers",
+ "host_moderation_tab_members": "Leden",
+ "host_moderation_hub_missing": "Hub niet gevonden.",
+ "host_rooms_modal_title": "Kamers: {hub}",
"host_rooms_select": "Selecteer een kamer om activiteit te bekijken",
"host_room_activity": "Recente activiteit",
"host_no_activity": "Geen recente activiteit in deze kamer",
@@ -2926,5 +2948,22 @@
"search_no_results": "Geen berichten komen overeen met je zoekopdracht",
"popout_channel": "Openen in nieuw venster",
"new_message_toast": "Nieuw bericht in #{room}"
+ },
+ "android_storage": {
+ "setup_title": "Choose where MeshChatX stores data",
+ "setup_desc": "File-manager storage is recommended. You can browse and back up your identity and messages under Android/data/com.meshchatx/files/. The app restarts when you change this during setup.",
+ "setup_external_title": "File-manager storage (recommended)",
+ "setup_external_desc": "Android/data/com.meshchatx/files/meshchatx — visible in many file managers.",
+ "setup_internal_title": "Private app storage",
+ "setup_internal_desc": "Hidden from file managers; only this app can access it.",
+ "setup_continue": "Continue",
+ "upgrade_title": "Move data to file-manager storage?",
+ "upgrade_desc": "Your MeshChatX data is in private app storage. Copy it to the file-manager folder so you can browse and back up files, then restart the app.",
+ "upgrade_copy": "Copy and restart",
+ "upgrade_stay_internal": "Stay on private storage",
+ "working": "Preparing…",
+ "copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
+ "restart_to_apply": "Restart the app to apply your storage choice.",
+ "failed": "Could not update storage location."
}
}
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 9bedff97..17e3850a 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -2162,7 +2162,14 @@
"failed_to_stop_session": "Failed to stop RNSH session",
"failed_to_remove_session": "Failed to remove RNSH session",
"failed_to_clear_output": "Failed to clear RNSH output",
- "failed_to_send_input": "Failed to send RNSH input"
+ "failed_to_send_input": "Failed to send RNSH input",
+ "tab_sessions_short": "Sessions",
+ "tab_connect_short": "Connect",
+ "tab_listen_short": "Listen",
+ "fullscreen": "Full screen",
+ "exit_fullscreen": "Exit full screen",
+ "show_sessions": "Sessions",
+ "hide_sessions": "Back to terminal"
},
"rnprobe": {
"network_diagnostics": "Диагностика сети",
@@ -2832,6 +2839,9 @@
"copy_hash": "Копировать хеш назначения",
"hash_copied": "Хеш назначения скопирован",
"host_start": "Запустить",
+ "host_status_running": "Работает",
+ "host_status_stopped": "Остановлен",
+ "host_hub_not_running": "Запустите хаб перед управлением комнатами или участниками.",
"host_stop": "Остановить",
"host_announce": "Объявить сейчас",
"host_announced": "Объявлено",
@@ -2853,8 +2863,8 @@
"host_room_created": "Комната создана",
"host_delete_room_confirm": "Удалить эту комнату?",
"host_room_deleted": "Комната удалена",
- "host_members_all_title": "Подключённые клиенты — {hub}",
- "host_members_room_title": "#{room} — {hub}",
+ "host_members_all_title": "Подключённые клиенты: {hub}",
+ "host_members_room_title": "#{room}: {hub}",
"host_member_rooms": "Комнаты",
"hub_icon": "Иконка хаба",
"hub_icon_choose": "Выбрать иконку",
@@ -2883,7 +2893,12 @@
"show_members": "Показать участников",
"hide_members": "Скрыть участников",
"discovery_refreshed": "Обзор обновлён",
- "host_members_search": "Поиск участников по имени, хешу или комнате...",
+ "host_members_search": "Поиск участников...",
+ "host_rooms_search": "Поиск комнат...",
+ "host_rooms_search_empty": "Нет комнат по вашему запросу.",
+ "host_no_rooms": "Пока нет комнат.",
+ "host_moderation_uptime": "Аптайм {time}",
+ "host_moderation_members": "{count} участников",
"host_members_select": "Выберите участника, чтобы просмотреть сообщения и модерировать",
"host_no_messages": "В журнале пока нет сообщений от этого пользователя",
"host_ban_hub": "Забанить на хабе",
@@ -2895,7 +2910,14 @@
"host_ban_confirm": "Забанить {name} на этом хабе?",
"host_room_ban_confirm": "Забанить {name} в #{room}?",
"host_manage_rooms": "Управление комнатами",
- "host_rooms_modal_title": "Комнаты — {hub}",
+ "host_moderate": "Модерация хаба",
+ "host_moderation_title": "Модерация",
+ "host_moderation_title_hub": "Модерация: {hub}",
+ "host_moderation_title_room": "Модерация: {hub} (#{room})",
+ "host_moderation_tab_rooms": "Комнаты",
+ "host_moderation_tab_members": "Участники",
+ "host_moderation_hub_missing": "Хаб не найден.",
+ "host_rooms_modal_title": "Комнаты: {hub}",
"host_rooms_select": "Выберите комнату для просмотра активности",
"host_room_activity": "Недавняя активность",
"host_no_activity": "Нет недавней активности в этой комнате",
@@ -2926,5 +2948,22 @@
"search_no_results": "Сообщения по запросу не найдены",
"popout_channel": "Открыть в новом окне",
"new_message_toast": "Новое сообщение в #{room}"
+ },
+ "android_storage": {
+ "setup_title": "Choose where MeshChatX stores data",
+ "setup_desc": "File-manager storage is recommended. You can browse and back up your identity and messages under Android/data/com.meshchatx/files/. The app restarts when you change this during setup.",
+ "setup_external_title": "File-manager storage (recommended)",
+ "setup_external_desc": "Android/data/com.meshchatx/files/meshchatx — visible in many file managers.",
+ "setup_internal_title": "Private app storage",
+ "setup_internal_desc": "Hidden from file managers; only this app can access it.",
+ "setup_continue": "Continue",
+ "upgrade_title": "Move data to file-manager storage?",
+ "upgrade_desc": "Your MeshChatX data is in private app storage. Copy it to the file-manager folder so you can browse and back up files, then restart the app.",
+ "upgrade_copy": "Copy and restart",
+ "upgrade_stay_internal": "Stay on private storage",
+ "working": "Preparing…",
+ "copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
+ "restart_to_apply": "Restart the app to apply your storage choice.",
+ "failed": "Could not update storage location."
}
}
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index e7abeec6..e4081360 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -2251,7 +2251,14 @@
"failed_to_stop_session": "Failed to stop RNSH session",
"failed_to_remove_session": "Failed to remove RNSH session",
"failed_to_clear_output": "Failed to clear RNSH output",
- "failed_to_send_input": "Failed to send RNSH input"
+ "failed_to_send_input": "Failed to send RNSH input",
+ "tab_sessions_short": "Sessions",
+ "tab_connect_short": "Connect",
+ "tab_listen_short": "Listen",
+ "fullscreen": "Full screen",
+ "exit_fullscreen": "Exit full screen",
+ "show_sessions": "Sessions",
+ "hide_sessions": "Back to terminal"
},
"rnprobe": {
"network_diagnostics": "网络诊断",
@@ -2832,6 +2839,9 @@
"copy_hash": "复制目标哈希",
"hash_copied": "已复制目标哈希",
"host_start": "启动",
+ "host_status_running": "运行中",
+ "host_status_stopped": "已停止",
+ "host_hub_not_running": "请先启动中心,再管理房间或成员。",
"host_stop": "停止",
"host_announce": "立即通告",
"host_announced": "已通告",
@@ -2853,8 +2863,8 @@
"host_room_created": "房间已创建",
"host_delete_room_confirm": "删除此房间?",
"host_room_deleted": "房间已删除",
- "host_members_all_title": "已连接客户端 — {hub}",
- "host_members_room_title": "#{room} — {hub}",
+ "host_members_all_title": "已连接客户端:{hub}",
+ "host_members_room_title": "#{room}:{hub}",
"host_member_rooms": "房间",
"hub_icon": "中心图标",
"hub_icon_choose": "选择图标",
@@ -2883,7 +2893,12 @@
"show_members": "显示成员",
"hide_members": "隐藏成员",
"discovery_refreshed": "已刷新发现",
- "host_members_search": "按名称、哈希或房间搜索成员...",
+ "host_members_search": "搜索成员...",
+ "host_rooms_search": "搜索房间...",
+ "host_rooms_search_empty": "没有匹配的房间。",
+ "host_no_rooms": "暂无房间。",
+ "host_moderation_uptime": "运行 {time}",
+ "host_moderation_members": "{count} 名成员",
"host_members_select": "选择成员以查看消息并进行管理",
"host_no_messages": "日志中尚无此用户的消息",
"host_ban_hub": "从中心封禁",
@@ -2895,7 +2910,14 @@
"host_ban_confirm": "在中心封禁 {name}?",
"host_room_ban_confirm": "在 #{room} 封禁 {name}?",
"host_manage_rooms": "管理房间",
- "host_rooms_modal_title": "房间 — {hub}",
+ "host_moderate": "管理 Hub",
+ "host_moderation_title": "管理",
+ "host_moderation_title_hub": "管理:{hub}",
+ "host_moderation_title_room": "管理:{hub}(#{room})",
+ "host_moderation_tab_rooms": "房间",
+ "host_moderation_tab_members": "成员",
+ "host_moderation_hub_missing": "未找到 Hub。",
+ "host_rooms_modal_title": "房间:{hub}",
"host_rooms_select": "选择房间以查看活动",
"host_room_activity": "最近活动",
"host_no_activity": "此房间暂无最近活动",
@@ -2926,5 +2948,22 @@
"search_no_results": "没有匹配搜索的消息",
"popout_channel": "在新窗口中打开",
"new_message_toast": "#{room} 中有新消息"
+ },
+ "android_storage": {
+ "setup_title": "Choose where MeshChatX stores data",
+ "setup_desc": "File-manager storage is recommended. You can browse and back up your identity and messages under Android/data/com.meshchatx/files/. The app restarts when you change this during setup.",
+ "setup_external_title": "File-manager storage (recommended)",
+ "setup_external_desc": "Android/data/com.meshchatx/files/meshchatx — visible in many file managers.",
+ "setup_internal_title": "Private app storage",
+ "setup_internal_desc": "Hidden from file managers; only this app can access it.",
+ "setup_continue": "Continue",
+ "upgrade_title": "Move data to file-manager storage?",
+ "upgrade_desc": "Your MeshChatX data is in private app storage. Copy it to the file-manager folder so you can browse and back up files, then restart the app.",
+ "upgrade_copy": "Copy and restart",
+ "upgrade_stay_internal": "Stay on private storage",
+ "working": "Preparing…",
+ "copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
+ "restart_to_apply": "Restart the app to apply your storage choice.",
+ "failed": "Could not update storage location."
}
}
diff --git a/tests/backend/test_android_rnode.py b/tests/backend/test_android_rnode.py
index 05f38101..81e4d960 100644
--- a/tests/backend/test_android_rnode.py
+++ b/tests/backend/test_android_rnode.py
@@ -7,6 +7,7 @@ from meshchatx.meshchat import ReticulumMeshChat
def test_disable_rnode_interfaces_on_android(tmp_path):
+ """Unconditional disable helper still clears RNode interfaces on Android."""
config_path = tmp_path / "config"
config_path.write_text(
"""[reticulum]
diff --git a/tests/backend/test_lxmf_propagation_full.py b/tests/backend/test_lxmf_propagation_full.py
index 9147626f..2b6c45a3 100644
--- a/tests/backend/test_lxmf_propagation_full.py
+++ b/tests/backend/test_lxmf_propagation_full.py
@@ -1,5 +1,6 @@
# SPDX-License-Identifier: 0BSD
+import asyncio
import json
import shutil
import tempfile
@@ -121,6 +122,7 @@ async def test_lxmf_sync_flow(mock_app):
await route.handler(None)
break
+ await asyncio.sleep(0.05)
mock_router.request_messages_from_propagation_node.assert_called_once()
# Check status (Receiving)
@@ -152,6 +154,7 @@ async def test_lxmf_sync_requests_path_before_sync(mock_app):
with patch("meshchatx.meshchat.RNS.Transport.has_path", return_value=False):
with patch("meshchatx.meshchat.RNS.Transport.request_path") as mock_request:
await sync_handler(None)
+ await asyncio.sleep(0.05)
mock_request.assert_called_with(outbound)
mock_router.request_messages_from_propagation_node.assert_called_with(
mock_app.current_context.identity
@@ -327,6 +330,7 @@ async def test_user_provided_node_hash(mock_app):
if r.path == "/api/v1/lxmf/propagation-node/sync"
)
await sync_handler(None)
+ await asyncio.sleep(0.05)
# Verify the router was told to sync for our identity
mock_app.current_context.message_router.request_messages_from_propagation_node.assert_called_with(
diff --git a/tests/backend/test_lxmf_propagation_sync_integration.py b/tests/backend/test_lxmf_propagation_sync_integration.py
index c7af2e0d..eeb0d8a9 100644
--- a/tests/backend/test_lxmf_propagation_sync_integration.py
+++ b/tests/backend/test_lxmf_propagation_sync_integration.py
@@ -1,5 +1,6 @@
# SPDX-License-Identifier: 0BSD
+import asyncio
import json
import shutil
import tempfile
@@ -145,6 +146,7 @@ async def test_remote_propagation_sync_transitions_path_requested_to_complete(
):
first_sync = await sync_handler(None)
assert first_sync.status == 200
+ await asyncio.sleep(0.05)
first_status = json.loads((await status_handler(None)).body)[
"propagation_node_status"
@@ -153,6 +155,7 @@ async def test_remote_propagation_sync_transitions_path_requested_to_complete(
second_sync = await sync_handler(None)
assert second_sync.status == 200
+ await asyncio.sleep(0.05)
second_status = json.loads((await status_handler(None)).body)[
"propagation_node_status"
diff --git a/tests/backend/test_lxmf_sync.py b/tests/backend/test_lxmf_sync.py
index 32166566..7b194b1c 100644
--- a/tests/backend/test_lxmf_sync.py
+++ b/tests/backend/test_lxmf_sync.py
@@ -1,5 +1,6 @@
# SPDX-License-Identifier: 0BSD
+import asyncio
import json
import shutil
import tempfile
@@ -104,6 +105,7 @@ async def test_lxmf_sync_endpoints(mock_app):
response = await sync_handler(None)
assert response.status == 200
+ await asyncio.sleep(0.05)
mock_app.current_context.message_router.request_messages_from_propagation_node.assert_called_once()
# 3. Test status change to complete
@@ -143,6 +145,7 @@ async def test_specific_node_hash_validation(mock_app):
mock_app.current_context.message_router.get_outbound_propagation_node.return_value = expected_bytes
await sync_handler(None)
+ await asyncio.sleep(0.05)
mock_app.current_context.message_router.request_messages_from_propagation_node.assert_called_once()
@@ -175,6 +178,7 @@ async def test_status_includes_sync_storage_and_confirmation_metrics(mock_app):
),
):
await sync_handler(None)
+ await asyncio.sleep(0.05)
response = await status_handler(None)
data = json.loads(response.body)["propagation_node_status"]
@@ -229,6 +233,7 @@ async def test_status_hidden_metric_is_clamped_to_zero(mock_app):
),
):
await sync_handler(None)
+ await asyncio.sleep(0.05)
response = await status_handler(None)
data = json.loads(response.body)["propagation_node_status"]
diff --git a/tests/backend/test_propagation_sync_cancel.py b/tests/backend/test_propagation_sync_cancel.py
new file mode 100644
index 00000000..5eb31f1e
--- /dev/null
+++ b/tests/backend/test_propagation_sync_cancel.py
@@ -0,0 +1,92 @@
+# SPDX-License-Identifier: 0BSD
+
+import asyncio
+import threading
+from unittest.mock import MagicMock, patch
+
+import LXMF
+import pytest
+
+from meshchatx.meshchat import ReticulumMeshChat
+
+PR_IDLE = LXMF.LXMRouter.PR_IDLE
+PR_LINK_ESTABLISHING = LXMF.LXMRouter.PR_LINK_ESTABLISHING
+PR_PATH_REQUESTED = LXMF.LXMRouter.PR_PATH_REQUESTED
+
+
+@pytest.mark.asyncio
+async def test_request_propagation_node_messages_handles_eof_error():
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ ctx = MagicMock()
+ router = MagicMock()
+ router.PR_IDLE = PR_IDLE
+ router.propagation_transfer_state = PR_LINK_ESTABLISHING
+ router.request_messages_from_propagation_node.side_effect = EOFError()
+ ctx.message_router = router
+ ctx.identity = MagicMock()
+
+ with patch.object(app, "send_config_to_websocket_clients", return_value=None):
+ await app._request_propagation_node_messages(context=ctx)
+
+ assert router.propagation_transfer_state == PR_IDLE
+ assert router.propagation_transfer_progress == 0.0
+
+
+def test_stop_propagation_node_sync_forces_idle_when_cancel_leaves_active_state():
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ ctx = MagicMock()
+ router = MagicMock()
+ router.PR_IDLE = PR_IDLE
+ router.PR_PATH_REQUESTED = PR_PATH_REQUESTED
+ router.PR_LINK_ESTABLISHING = PR_LINK_ESTABLISHING
+ router.PR_LINK_ESTABLISHED = LXMF.LXMRouter.PR_LINK_ESTABLISHED
+ router.PR_REQUEST_SENT = LXMF.LXMRouter.PR_REQUEST_SENT
+ router.PR_RECEIVING = LXMF.LXMRouter.PR_RECEIVING
+ router.PR_RESPONSE_RECEIVED = LXMF.LXMRouter.PR_RESPONSE_RECEIVED
+ router.propagation_transfer_state = PR_PATH_REQUESTED
+ ctx.message_router = router
+
+ app.stop_propagation_node_sync(context=ctx)
+
+ router.cancel_propagation_node_requests.assert_called_once()
+ assert router.propagation_transfer_state == PR_IDLE
+ assert router.propagation_transfer_progress == 0.0
+
+
+@pytest.mark.asyncio
+async def test_sync_propagation_nodes_returns_before_request_thread_finishes():
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ ctx = MagicMock()
+ router = MagicMock()
+ router.PR_IDLE = PR_IDLE
+ router.PR_COMPLETE = LXMF.LXMRouter.PR_COMPLETE
+ router.propagation_transfer_state = PR_IDLE
+ router.get_outbound_propagation_node.return_value = b"\x22" * 16
+ router.propagation_destination = MagicMock(hash=b"\x11" * 16)
+ ctx.message_router = router
+ ctx.identity = MagicMock()
+ ctx.config = MagicMock()
+ ctx.database = MagicMock()
+ ctx.database.messages.count_lxmf_messages.return_value = 0
+ ctx.database.messages.count_lxmf_messages_by_state.return_value = 0
+
+ request_started = threading.Event()
+ request_finished = threading.Event()
+
+ def slow_request(_identity):
+ request_started.set()
+ request_finished.wait(timeout=5.0)
+
+ router.request_messages_from_propagation_node.side_effect = slow_request
+
+ with (
+ patch.object(app, "_begin_propagation_sync_metrics"),
+ patch.object(app, "send_config_to_websocket_clients", return_value=None),
+ ):
+ await app.sync_propagation_nodes(context=ctx, force=False)
+ await asyncio.sleep(0.05)
+ assert request_started.is_set()
+ assert not request_finished.is_set()
+
+ request_finished.set()
+ await asyncio.sleep(0.05)
diff --git a/tests/backend/test_rnode_support.py b/tests/backend/test_rnode_support.py
new file mode 100644
index 00000000..f54bad2d
--- /dev/null
+++ b/tests/backend/test_rnode_support.py
@@ -0,0 +1,44 @@
+# SPDX-License-Identifier: 0BSD
+
+
+from meshchatx.src.backend import rnode_support
+
+
+def test_guard_disables_rnode_when_usbserial4a_missing(tmp_path, monkeypatch):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+ [[RNode Serial]]
+ type = RNodeInterface
+ interface_enabled = True
+ port = ble://aa:bb:cc:dd:ee:ff
+""",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True)
+ monkeypatch.setattr(rnode_support, "android_usbserial4a_available", lambda: False)
+
+ assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is True
+ assert "interface_enabled = false" in config_path.read_text(encoding="utf-8")
+
+
+def test_guard_keeps_rnode_when_usbserial4a_available(tmp_path, monkeypatch):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+ [[RNode Serial]]
+ type = RNodeInterface
+ interface_enabled = True
+""",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True)
+ monkeypatch.setattr(rnode_support, "android_usbserial4a_available", lambda: True)
+
+ assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is False
+ assert "interface_enabled = True" in config_path.read_text(encoding="utf-8")
+
+
+def test_rnode_serial_supported_on_desktop(monkeypatch):
+ monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: False)
+ assert rnode_support.rnode_serial_supported() is True
diff --git a/tests/backend/test_rrc_server.py b/tests/backend/test_rrc_server.py
index fa69b7c9..d30b0eca 100644
--- a/tests/backend/test_rrc_server.py
+++ b/tests/backend/test_rrc_server.py
@@ -2,6 +2,8 @@
"""Unit tests for the RRC hub server (hosting) layer."""
+import time
+
from meshchatx.src.backend.rrc import protocol as proto
from meshchatx.src.backend.rrc.manager import RRCManager, RRCHub
from meshchatx.src.backend.rrc.server import (
@@ -277,6 +279,13 @@ def test_ping_returns_pong():
assert out[0][1][proto.K_BODY] == 123
+def test_uptime_seconds_in_summary():
+ server = make_running_server()
+ assert server.to_dict()["uptime_seconds"] == 0
+ server._started_at = time.time() - 90
+ assert server.to_dict()["uptime_seconds"] >= 90
+
+
def test_register_and_unregister_room():
server = make_server()
server.register_room("Lobby", topic="hi")
diff --git a/tests/frontend/ConversationViewer.outboundPropagationStatus.test.js b/tests/frontend/ConversationViewer.outboundPropagationStatus.test.js
index 76e87eb0..9f44a8c0 100644
--- a/tests/frontend/ConversationViewer.outboundPropagationStatus.test.js
+++ b/tests/frontend/ConversationViewer.outboundPropagationStatus.test.js
@@ -77,6 +77,17 @@ describe("ConversationViewer outbound propagation status", () => {
expect(wrapper.vm.outboundSentStatusTitle(null)).toBe("");
});
+ it("outboundTransferProgressPercent and label track resource transfer", () => {
+ const wrapper = mountViewer();
+ expect(wrapper.vm.outboundTransferProgressPercent({ state: "sending", progress: 42.5 })).toBe(43);
+ expect(wrapper.vm.outboundSendingProgressLabel({ state: "sending", progress: 42.5 })).toBe("43%");
+ expect(wrapper.vm.outboundTransferProgressPercent({ state: "sending", progress: 0 })).toBe(0);
+ expect(wrapper.vm.outboundTransferProgressPercent({ state: "outbound", progress: 50 })).toBe(50);
+ expect(wrapper.vm.outboundTransferProgressPercent({ state: "outbound", progress: 0 })).toBeNull();
+ expect(wrapper.vm.outboundTransferProgressPercent({ state: "sending", _pendingPathfinding: true })).toBeNull();
+ expect(wrapper.vm.outboundSendingProgressLabel(null)).toBeNull();
+ });
+
it("outboundSendingStatusTooltip uses propagation pending strings for propagated method", () => {
const wrapper = mountViewer();
const withProgress = wrapper.vm.outboundSendingStatusTooltip({
diff --git a/tests/frontend/RNSHManagerPage.test.js b/tests/frontend/RNSHManagerPage.test.js
index 71189d99..8a1b47e0 100644
--- a/tests/frontend/RNSHManagerPage.test.js
+++ b/tests/frontend/RNSHManagerPage.test.js
@@ -108,4 +108,28 @@ describe("RNSHManagerPage.vue", () => {
expect(wrapper.vm.outputsBySession[SESSION_ID]).toContain("line2");
});
+
+ it("toggles session fullscreen and closes mobile sessions drawer on narrow screens", async () => {
+ const wrapper = mount(RNSHManagerPage, { global: mountToolsPageGlobals() });
+ await vi.waitFor(() => expect(wrapper.vm.sessions.length).toBe(1));
+
+ wrapper.vm.isNarrowScreen = true;
+ wrapper.vm.mobileSessionsOpen = true;
+ wrapper.vm.toggleSessionFullscreen();
+ expect(wrapper.vm.sessionFullscreen).toBe(true);
+ expect(wrapper.vm.mobileSessionsOpen).toBe(false);
+
+ wrapper.vm.toggleSessionFullscreen();
+ expect(wrapper.vm.sessionFullscreen).toBe(false);
+ });
+
+ it("selectSession closes mobile sessions list on narrow screens", async () => {
+ const wrapper = mount(RNSHManagerPage, { global: mountToolsPageGlobals() });
+ await vi.waitFor(() => expect(wrapper.vm.sessions.length).toBe(1));
+
+ wrapper.vm.isNarrowScreen = true;
+ wrapper.vm.mobileSessionsOpen = true;
+ wrapper.vm.selectSession(SESSION_ID);
+ expect(wrapper.vm.mobileSessionsOpen).toBe(false);
+ });
});
diff --git a/tests/frontend/RelayChatPage.test.js b/tests/frontend/RelayChatPage.test.js
index cbb777ad..ff2a67d5 100644
--- a/tests/frontend/RelayChatPage.test.js
+++ b/tests/frontend/RelayChatPage.test.js
@@ -368,23 +368,23 @@ describe("RelayChatPage.vue", () => {
);
});
- it("loads hosted hub members when opening the members dialog", async () => {
+ it("loads hosted hub members when opening the moderation page", async () => {
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.serverHubs.length).toBe(1));
const hub = wrapper.vm.serverHubs[0];
- wrapper.vm.openHostMembers(hub, "lobby");
+ wrapper.vm.view = "host";
+ wrapper.vm.openHostModeration(hub, { tab: "members", room: "lobby" });
await wrapper.vm.$nextTick();
- const modal = wrapper.findComponent({ name: "RelayHostMembersModal" });
- await vi.waitFor(() => expect(modal.vm.members).toHaveLength(1));
+ const page = wrapper.findComponent({ name: "RelayHostModerationPage" });
+ await vi.waitFor(() => expect(page.vm.members).toHaveLength(1));
expect(axiosMock.get).toHaveBeenCalledWith(`/api/v1/rrc/servers/${HOSTED_HUB_ID}/members`, {
params: { room: "lobby" },
});
- expect(wrapper.vm.hostMembersModal.open).toBe(true);
- expect(wrapper.vm.hostMembersModal.hub.id).toBe(HOSTED_HUB_ID);
- expect(modal.vm.members[0].name).toBe("alice");
+ expect(wrapper.vm.hostModeration.hub.id).toBe(HOSTED_HUB_ID);
+ expect(page.vm.members[0].name).toBe("alice");
});
it("refreshes hosted hubs on a server change websocket event", async () => {
diff --git a/tests/frontend/RelayHostMembersModal.test.js b/tests/frontend/RelayHostModerationPage.test.js
similarity index 77%
rename from tests/frontend/RelayHostMembersModal.test.js
rename to tests/frontend/RelayHostModerationPage.test.js
index dfa0b41c..7122737b 100644
--- a/tests/frontend/RelayHostMembersModal.test.js
+++ b/tests/frontend/RelayHostModerationPage.test.js
@@ -1,7 +1,7 @@
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import RelayHostMembersModal from "@/components/relay/RelayHostMembersModal.vue";
+import RelayHostModerationPage from "@/components/relay/RelayHostModerationPage.vue";
import DialogUtils from "@/js/DialogUtils";
import ToastUtils from "@/js/ToastUtils";
import { mountToolsPageGlobals } from "./testI18n.js";
@@ -26,7 +26,7 @@ const PEER_HASH = "00112233445566778899aabbccddeeff";
const LOCAL_HASH = "ffeeddccbbaa99887766554433221100";
function makeHub() {
- return { id: HUB_ID, name: "Hosted" };
+ return { id: HUB_ID, name: "Hosted", running: true };
}
function makeMember(overrides = {}) {
@@ -38,7 +38,7 @@ function makeMember(overrides = {}) {
};
}
-describe("RelayHostMembersModal.vue", () => {
+describe("RelayHostModerationPage.vue", () => {
beforeEach(() => {
vi.clearAllMocks();
DialogUtils.confirm.mockResolvedValue(true);
@@ -50,25 +50,28 @@ describe("RelayHostMembersModal.vue", () => {
if (url.includes("/members")) {
return { data: { members: [makeMember()] } };
}
+ if (url.includes("/activity")) {
+ return { data: { rooms: [], recent: [] } };
+ }
return { data: {} };
}),
post: vi.fn(async () => ({ data: { message: "ok" } })),
};
});
- const mountModal = (props = {}) =>
- mount(RelayHostMembersModal, {
+ const mountPage = (props = {}) =>
+ mount(RelayHostModerationPage, {
props: {
- open: true,
hub: makeHub(),
- room: null,
+ initialTab: "members",
+ roomFilter: null,
...props,
},
global: mountToolsPageGlobals(),
});
- it("kicks using the member room when the modal has no room filter", async () => {
- const wrapper = mountModal();
+ it("kicks using the member room when there is no room filter", async () => {
+ const wrapper = mountPage();
await wrapper.vm.fetchMembers();
await wrapper.vm.moderate(makeMember(), "kick");
@@ -80,8 +83,8 @@ describe("RelayHostMembersModal.vue", () => {
});
});
- it("uses the modal room filter when set", async () => {
- const wrapper = mountModal({ room: "ops" });
+ it("uses the room filter when set", async () => {
+ const wrapper = mountPage({ roomFilter: "ops" });
await wrapper.vm.fetchMembers();
await wrapper.vm.moderate(makeMember({ rooms: ["lobby", "ops"] }), "kick");
@@ -94,7 +97,7 @@ describe("RelayHostMembersModal.vue", () => {
});
it("blocks moderating the local identity", async () => {
- const wrapper = mountModal();
+ const wrapper = mountPage();
await wrapper.vm.ensureLocalIdentity();
await wrapper.vm.moderate(makeMember({ hash: LOCAL_HASH }), "kick");
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────